Medienquellenerweiterungen für Audio

Dale Curtis
Dale Curtis

Einleitung

Media Source Extensions (MSE) bieten eine erweiterte Zwischenspeicherung und Wiedergabesteuerung für die HTML5-Elemente <audio> und <video>. Sie wurden ursprünglich für Dynamic Adaptive Streaming over HTTP (DASH)-basierte Videoplayer entwickelt. Im Folgenden erfahren Sie, wie sie für Audioanzeigen eingesetzt werden können, insbesondere für die lückenlose Wiedergabe.

Wahrscheinlich hast du schon ein Musikalbum gehört, bei dem Songs nahtlos über einen Titel geflogen sind. Vielleicht hörst du gerade sogar eines. Künstler schaffen diese lückenlose Wiedergabe sowohl als künstlerische Entscheidung als auch als Artefakt aus Schallplatten und CDs, bei denen die Audioinhalte in einem fortlaufenden Stream geschrieben wurden. Durch die Art und Weise, wie moderne Audio-Codecs wie MP3 und AAC funktionieren, geht dieses nahtlose Klangerlebnis heute oft verloren.

Im Folgenden erfahren Sie, warum dies so ist. Beginnen wir aber erst einmal mit einer Demonstration. Unten sehen Sie die ersten 30 Sekunden des hervorragenden Sintel, aufgeteilt in fünf separate MP3-Dateien und mit MSE wieder zusammengesetzt. Die roten Linien kennzeichnen Lücken, die bei der Erstellung (Codierung) der jeweiligen MP3-Datei entstanden sind. An diesen Stellen sind Störungen zu hören.

Demo

Igitt! Das ist keine gute Erfahrung. Wir könnten das besser machen. Mit etwas mehr Aufwand und genau denselben MP3-Dateien in der obigen Demo können wir diese lästigen Lücken mithilfe von MSE beseitigen. Die grünen Linien in der nächsten Demo geben an, wo die Dateien verbunden und die Lücken geschlossen wurden. In Chrome 38 und höher erfolgt die Wiedergabe nahtlos.

Demo

Es gibt verschiedene Möglichkeiten, um lückenlose Inhalte zu erstellen. In dieser Demo konzentrieren wir uns auf die Dateitypen, die ein normaler Nutzer zur Verfügung hat. Jede Datei wurde separat codiert, ohne dass die voran- oder nachgestellten Audiosegmente berücksichtigt werden.

Grundlegende Einrichtung

Sehen wir uns zuerst die grundlegende Einrichtung einer MediaSource-Instanz an. Medienquellenerweiterungen sind, wie der Name schon sagt, nur Erweiterungen der vorhandenen Medienelemente. Unten wird dem Quellattribut eines Audioelements ein Object URL zugewiesen, der unsere MediaSource-Instanz darstellt, so wie Sie auch eine Standard-URL festlegen würden.

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 ein SourceBuffer erstellen. Im Beispiel oben wird ein audio/mpeg erstellt, mit dem MP3-Segmente geparst und decodiert werden können. Es gibt weitere Typen.

Ungewöhnliche Wellenformen

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

Ende von sintel_0.mp3

Was hat es mit all diesen null (stummen) Proben? Tatsächlich sind sie auf Komprimierungsartefakte zurückzuführen, die während der Codierung eingeführt wurden. Nahezu jeder Encoder hat eine Art 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 ein Innenrand hinzugefügt. Wenn wir einen Blick auf den Track sintel_1.mp3 werfen, sehen wir, dass weitere 576 Samples mit dem Padding im Vordergrund vorhanden sind. Der Abstand kann je nach Encoder und Inhalt variieren. Wir kennen die genauen Werte basierend auf dem in jeder Datei enthaltenen metadata.

Start von sintel_1.mp3

Start von sintel_1.mp3

Die stillen Abschnitte am Anfang und Ende jeder Datei sind für die Fehler zwischen den Segmenten in der vorherigen Demo verantwortlich. Für eine lückenlose Wiedergabe müssen diese Abschnitte ohne Ton entfernt werden. Mit MediaSource geht das ganz einfach. Unten ändern wir die Methode onAudioLoaded() so, dass ein Append-Fenster und ein Zeitstempel-Offset verwendet werden, 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 noch einmal die Wellenform anschauen, nachdem wir die Anfügefenster angewendet haben. 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 möglich.

sintel_0.mp3 und sintel_1.mp3 werden verbunden

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 Rolle für Container oder Codecs spielt. Das bedeutet, dass alle diese Techniken unabhängig vom Container- oder Codec-Typ funktionieren. Unten können Sie sich die ursprüngliche DASH-fähige fragmentierte MP4-Demo statt einer MP3-Demo ansehen.

Demo

Weitere Informationen finden Sie in den nachfolgenden Anhängen. Sie enthalten ausführlichere Informationen zur lückenlosen Erstellung von Inhalten und zum Parsen von Metadaten. Sie können sich auch gapless.js ansehen, um sich den Code genauer anzusehen, der dieser Demo zugrunde liegt.

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

Anhang A: Lückenlose Inhalte erstellen

Es kann schwierig sein, Inhalte ohne Unterbrechungen zu erstellen. Unten zeigen wir dir Schritt für Schritt, wie du die in dieser Demo verwendeten Medien von Sintel erstellst. Als Erstes benötigen Sie eine Kopie des 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. Außerdem möchten wir ein 2,5-sekündiges Ausblenden von 28 Sekunden hinzufügen, um Klicks nach der Wiedergabe zu vermeiden. Mit der folgenden FFmpeg-Befehlszeile können wir all das erreichen 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.Es ist am einfachsten, eine Wave zu verwenden, da fast jeder Encoder die Aufnahme dieser Datei unterstützt. Wir können das genau mit FFmpeg machen, woraufhin wir sintel_0.wav, sintel_1.wav, sintel_2.wav, sintel_3.wav und sintel_4.wav erhalten.

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 MP3-Dateien. LAME bietet mehrere Optionen zum Erstellen lückenloser Inhalte. Wenn Sie die Kontrolle über den Inhalt haben, können Sie --nogap mit einer Batch-Codierung aller Dateien verwenden, um Innenabstände zwischen Segmenten zu vermeiden. In dieser Demo möchten wir jedoch das Padding verwenden, 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 nicht nötig, um die MP3-Dateien zu erstellen. Kommen wir nun zum Erstellen der fragmentierten MP4-Dateien. Wir folgen dabei der Anleitung von Apple zum Erstellen von Medien, die für iTunes gemastert werden. Unten werden die Wave-Dateien gemäß der Anleitung in CAF-Zwischendateien konvertiert, bevor sie mit den empfohlenen Parametern in einem MP4-Container als AAC 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

Wir haben jetzt mehrere M4A-Dateien, die entsprechend fragmentiert werden 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 korrekten Metadaten für eine lückenlose Wiedergabe. 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 der lückenlosen Metadaten schwierig sein, da es keine Standardmethode für die Speicherung gibt. Im Folgenden erfährst du, wie die beiden gängigsten Encoder, LAME und iTunes, ihre lückenlosen Metadaten speichern. Richten wir zuerst 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 behandeln zuerst das 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 wie folgt:

iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00

Sie wird in ein ID3-Tag im MP3-Container und in ein Metadaten-Atom im MP4-Container geschrieben. Für unsere Zwecke können wir das erste 0000000-Token ignorieren. Die nächsten drei Tokens sind die Auffüllung am vorderen Rand, die Auffüllung am Ende und die Gesamtzahl der Stichproben ohne Auffüllung. Wenn wir die einzelnen Werte durch die Abtastrate der Audioinhalte dividieren, 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 in einem stummen MPEG-Frame befindet. Decodierer, die den Xing-Header nicht verstehen, sind stumm geschaltet. Leider ist dieses Tag nicht immer vorhanden und verfügt über eine Reihe optionaler Felder. In dieser Demo haben wir die Kontrolle über die Medien, aber in der Praxis sind einige zusätzliche Prüfungen erforderlich, um festzustellen, ob lückenlose Metadaten tatsächlich verfügbar sind.

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

// 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-Stichproben 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 gibt es 3 Bytes für das Frontend und das End-Padding in jeweils 12 Bit.

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 der lückenlosen Inhalte. Es gibt jedoch unzählige Grenzfälle, daher sollten Sie vorsichtig sein, bevor Sie ähnlichen Code in der Produktion verwenden.

Anhang C: Bei der automatischen Speicherbereinigung

Der zu SourceBuffer-Instanzen gehörende Arbeitsspeicher wird aktiv gemäß dem Inhaltstyp, plattformspezifischen Limits und der aktuellen Wiedergabeposition automatisch bereinigt. In Chrome wird Arbeitsspeicher zuerst 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 die Lücke klein genug ist, oder ganz hängen geblieben ist, wenn sie zu groß ist. Keiner der beiden ist eine großartige Nutzererfahrung. Daher ist es wichtig, nicht zu viele Daten auf einmal anzuhängen und nicht mehr benötigte Bereiche manuell aus der Medienzeitleiste zu entfernen.

Bereiche können über die remove()-Methode für jede SourceBuffer entfernt werden. Dafür ist ein [start, end]-Bereich in Sekunden erforderlich. Ähnlich wie bei appendBuffer() löst jedes remove()-Ereignis ein updateend-Ereignis aus, sobald es abgeschlossen ist. Andere Entfernungen oder Anhänge sollten erst dann erfolgen, wenn das Ereignis ausgelöst wird.

In der Desktopversion von Chrome können Sie etwa 12 Megabyte an Audioinhalten und 150 Megabyte an Videoinhalten auf einmal speichern. Sie sollten sich nicht auf verschiedene Browser oder Plattformen auf diese Werte verlassen; sie sind also mit Sicherheit nicht repräsentativ für Mobilgeräte.

Die automatische Speicherbereinigung wirkt sich nur auf Daten aus, die zu 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 erneut anhängen.