Medienquellenerweiterungen für Audio

Dale Curtis
Dale Curtis

Einführung

Media Source Extensions (MSE) bieten erweiterte Pufferung und Wiedergabesteuerung für die HTML5-Elemente <audio> und <video>. Sie wurden ursprünglich entwickelt, um Dynamic Adaptive Streaming over HTTP (DASH)-basierte Videoplayer zu ermöglichen. Unten erfährst du, wie sie für Audio verwendet werden können, insbesondere für die lückenlose Wiedergabe.

Du hast wahrscheinlich schon einmal ein Musikalbum gehört, bei dem die Songs nahtlos ineinander übergingen. Vielleicht hörst du dir gerade sogar eins an. Künstler erstellen diese lückenlose Wiedergabe sowohl als künstlerische Entscheidung als auch als Artefakt von Vinylschallplatten und CDs, bei denen Audioinhalte als kontinuierlicher Stream aufgezeichnet wurden. Leider wird diese nahtlose Audiowiedergabe aufgrund der Funktionsweise moderner Audiocodecs wie MP3 und AAC heute oft nicht mehr erreicht.

Wir gehen unten näher darauf ein, warum das so ist. Aber fangen wir mit einer Demonstration an. Unten sehen Sie die ersten 30 Sekunden des hervorragenden Sintel-Videos, die in fünf separate MP3-Dateien aufgeteilt und mit MSE wieder zusammengesetzt wurden. Die roten Linien zeigen Lücken an, die beim Erstellen (Codieren) der einzelnen MP3-Dateien entstanden sind. An diesen Stellen hörst du Störungen.

Demo

Igitt! Das ist nicht optimal. Wir können das besser machen. Mit etwas mehr Aufwand können wir mit denselben MP3-Dateien wie in der Demo oben die lästigen Lücken mit MSE entfernen. Die grünen Linien in der nächsten Demo zeigen, wo die Dateien zusammengeführt und die Lücken entfernt wurden. In Chrome 38 und höher funktioniert die Wiedergabe nahtlos.

Demo

Es gibt verschiedene Möglichkeiten, nahtlose Inhalte zu erstellen. Für diese Demo konzentrieren wir uns auf die Art von Dateien, die ein normaler Nutzer möglicherweise hat. Bei der Codierung wurde jede Datei separat codiert, ohne Rücksicht auf die Audiosegmente davor oder danach.

Grundlegende Einrichtung

Sehen wir uns zuerst die grundlegende Einrichtung einer MediaSource-Instanz an. Medienquellerweiterungen sind, wie der Name schon sagt, nur Erweiterungen der vorhandenen Medienelemente. Unten weisen wir dem Attribut „source“ eines Audioelements eine Object URL zu, die unsere MediaSource-Instanz darstellt, genau wie bei einer Standard-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);

Sobald das MediaSource-Objekt verbunden ist, wird es initialisiert und schließlich ein sourceopen-Ereignis ausgelöst. An diesem Punkt können wir ein SourceBuffer erstellen. Im Beispiel oben erstellen wir eine audio/mpeg, die unsere MP3-Segmente parsen und decodieren kann. Es gibt noch andere Typen.

Anomalous Waveforms

Wir kommen gleich wieder auf den Code zurück. Sehen wir uns aber zuerst die Datei an, die wir gerade angehängt haben, insbesondere das Ende. Unten siehst du ein Diagramm der letzten 3.000 Stichproben aus dem sintel_0.mp3-Track, die über beide Kanäle gemittelt wurden. Jedes Pixel auf der roten Linie ist ein Float-Stichprobe im Bereich von [-1.0, 1.0].

Ende von sintel_0.mp3

Was ist mit all diesen Null- (Lautlos-)Samples los? Sie sind eigentlich auf Komprimierungsartefakte zurückzuführen, die beim Codieren entstehen. Fast jeder Encoder führt eine Art von Padding ein. In diesem Fall hat LAME dem Ende der Datei genau 576 Padding-Samples hinzugefügt.

Zusätzlich zum Padding am Ende wurde jeder Datei auch Padding am Anfang hinzugefügt. Wenn wir einen Blick auf den sintel_1.mp3-Track werfen, sehen wir, dass sich vorne weitere 576 Stichproben des Paddings befinden. Die Menge des Paddings variiert je nach Encoder und Inhalt. Die genauen Werte sind uns jedoch anhand des in jeder Datei enthaltenen metadata bekannt.

Anfang von sintel_1.mp3

Anfang von sintel_1.mp3

Die Stille am Anfang und Ende jeder Datei verursacht die Störungen zwischen den Segmenten in der vorherigen Demo. Um eine lückenlose Wiedergabe zu ermöglichen, müssen wir diese Stilleabschnitte entfernen. Glücklicherweise ist das mit MediaSource ganz einfach. Unten ändern wir unsere onAudioLoaded()-Methode, um ein Anfügefenster und einen Zeitstempelversatz 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);
}

Eine nahtlose Wellenform

Sehen wir uns an, was unser neuer Code erreicht hat. Sehen wir uns noch einmal die Wellenform an, nachdem wir die Suffixfenster angewendet haben. Unten siehst du, dass der stumme Bereich am Ende von sintel_0.mp3 (rot) und der stumme Bereich am Anfang von sintel_1.mp3 (blau) entfernt wurden. Dadurch entsteht ein nahtloser Übergang zwischen den Segmenten.

Zusammenführen von sintel_0.mp3 und sintel_1.mp3

Fazit

Damit haben wir alle fünf Segmente nahtlos zu einem zusammengefügt und sind am Ende unserer Demo angelangt. Bevor wir fortfahren, haben Sie vielleicht bemerkt, dass unsere onAudioLoaded()-Methode keine Berücksichtigung von Containern oder Codecs vorsieht. Das bedeutet, dass alle diese Techniken unabhängig vom Container- oder Codec-Typ funktionieren. Unten kannst du die ursprüngliche Demo im DASH-fähigen fragmentierten MP4-Format anstelle von MP3 noch einmal abspielen.

Demo

Weitere Informationen zum Erstellen von lückenlosen Inhalten und zum Parsen von Metadaten findest du in den Anhängen unten. Unter gapless.js finden Sie einen genaueren Blick auf den Code, der diese Demo antreibt.

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

Anhang A: Inhalte ohne Lücken erstellen

Es kann schwierig sein, nahtlose Inhalte zu erstellen. Unten wird beschrieben, wie die in dieser Demo verwendeten Sintel-Medien erstellt wurden. Zuerst benötigen Sie eine Kopie des verlustfreien FLAC-Soundtracks für Sintel. Zur Information ist unten die SHA1-Summe angegeben. Du benötigst die Tools 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 1-Snow_Fight.flac-Tracks auf. Außerdem möchten wir ab der 28.Sekunde ein 2,5-sekündiges Ausblenden hinzufügen, um Klicks nach Ende der Wiedergabe zu vermeiden. Mit der folgenden FFmpeg-Befehlszeile können wir all das tun und die Ergebnisse in sintel.flac speichern.

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.Wave ist am einfachsten zu verwenden, da fast jeder Encoder die Aufnahme unterstützt. Auch das ist mit FFmpeg möglich. Danach haben wir sintel_0.wav, sintel_1.wav, sintel_2.wav, sintel_3.wav und 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

Als Nächstes erstellen wir die MP3-Dateien. LAME bietet mehrere Optionen zum Erstellen von lückenlosen Inhalten. Wenn du die Kontrolle über die Inhalte hast, kannst du --nogap mit einer Batch-Codierung aller Dateien verwenden, um Padding zwischen den Segmenten vollständig zu vermeiden. Für diese Demo benötigen wir jedoch dieses Padding. Daher verwenden wir eine standardmäßige VBR-Codierung in hoher Qualität für die 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

Das ist alles, was zum Erstellen der MP3-Dateien erforderlich ist. Sehen wir uns nun an, wie die fragmentierten MP4-Dateien erstellt werden. Wir folgen der Anleitung von Apple zum Erstellen von Medien, die für iTunes optimiert sind. Unten konvertieren wir die Wave-Dateien gemäß der Anleitung in Zwischendateien im CAF-Format, bevor wir sie mit den empfohlenen Parametern als AAC in einem MP4-Container codieren.

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

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

Anhang B: Metadaten für Titel ohne Pausen parsen

Genau wie beim Erstellen von lückenlosen Inhalten kann das Parsen der Metadaten für lückenlose Inhalte schwierig sein, da es keine Standardmethode für die Speicherung gibt. Im Folgenden wird beschrieben, wie die beiden gängigsten Encoder, LAME und iTunes, ihre Metadaten für die verlustfreie Wiedergabe speichern. Beginnen wir mit der Einrichtung einiger Hilfsmethoden und eines ParseGaplessData()-Grundgerüsts, das wir oben verwendet haben.

// 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 beginnen mit dem iTunes-Metadatenformat von Apple, da es sich am einfachsten analysieren und erklären lässt. In MP3- und M4A-Dateien schreiben iTunes und afconvert einen kurzen Abschnitt in ASCII:

iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00

Dieser wird in einem ID3-Tag im MP3-Container und in einem Metadatenatom im MP4-Container geschrieben. Für unsere Zwecke können wir das erste 0000000-Token ignorieren. Die nächsten drei Tokens sind das Vorlauf-, das End-Padding und die Gesamtzahl der Padding-freien Samples. Wenn wir diese Werte durch die Abtastrate der Audiodatei teilen, erhalten wir die Dauer der einzelnen Samples.

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

Die meisten Open-Source-MP3-Encoder speichern die Metadaten für die nahtlose Wiedergabe in einem speziellen Xing-Header, der sich in einem stummen MPEG-Frame befindet. Er ist stumm, damit Dekodierer, die den Xing-Header nicht verstehen, einfach nur Stille wiedergeben. Leider ist dieses Tag nicht immer vorhanden und enthält eine Reihe von optionalen Feldern. Für diese Demo haben wir die Kontrolle über die Medien. In der Praxis sind jedoch einige zusätzliche Prüfungen erforderlich, um festzustellen, wann lückenlose Metadaten tatsächlich verfügbar sind.

Zuerst parsen wir die Gesamtzahl der Samples. Der Einfachheit halber lesen wir das aus dem Xing-Header, es könnte aber auch aus dem normalen MPEG-Audio-Header erstellt werden. Xing-Header können entweder mit einem Xing- oder einem Info-Tag gekennzeichnet werden. Genau 4 Byte nach diesem Tag folgen 32 Bit, die die Gesamtzahl der Frames in der Datei darstellen. Wenn wir diesen Wert mit der Anzahl der Samples pro Frame multiplizieren, erhalten wir die Gesamtzahl der Samples 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.

Nachdem wir die Gesamtzahl der Samples ermittelt haben, können wir mit dem Lesen der Anzahl der Padding-Samples fortfahren. Je nach Encoder wird dieser Wert möglicherweise unter einem LAME- oder Lavf-Tag geschrieben, das im Xing-Header verschachtelt ist. Genau 17 Byte nach dieser Kopfzeile folgen 3 Byte, die jeweils 12 Bit Padding am Anfang und Ende 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 der überwiegenden Mehrheit der inhaltsfreien Inhalte. Grenzfälle gibt es jedoch in Hülle und Fülle. Daher ist Vorsicht geboten, bevor Sie ähnlichen Code in der Produktion verwenden.

Anhang C: Automatische Speicherbereinigung

Der Arbeitsspeicher von SourceBuffer-Instanzen wird gemäß Inhaltstyp, plattformspezifischen Limits und aktueller Wiedergabeposition aktiv befreit. In Chrome wird der Arbeitsspeicher zuerst aus bereits wiedergegebenen Puffern zurückgefordert. Wenn die Speichernutzung jedoch die plattformspezifischen Limits überschreitet, wird Speicher aus nicht abgespielten Puffern entfernt.

Wenn die Wiedergabe aufgrund des zurückgeforderten Arbeitsspeichers eine Lücke in der Zeitleiste erreicht, kann es zu Rucklern kommen, wenn die Lücke klein genug ist, oder die Wiedergabe vollständig pausieren, wenn die Lücke zu groß ist. Beides ist nicht ideal. Daher solltest du zu viele Daten nicht gleichzeitig anhängen und Bereiche, die nicht mehr erforderlich sind, manuell aus der Medienzeitachse entfernen.

Bereiche können über die Methode remove() für jede SourceBuffer entfernt werden. Dies dauert einige Sekunden.[start, end] Ähnlich wie bei appendBuffer() löst jedes remove()-Ereignis ein updateend-Ereignis aus, sobald es abgeschlossen ist. Andere Entfernungen oder Anhänge sollten erst nach dem Auslösen des Ereignisses erfolgen.

Auf dem Computer können Sie in Chrome etwa 12 Megabyte Audioinhalte und 150 Megabyte Videoinhalte gleichzeitig im Arbeitsspeicher halten. Sie sollten sich nicht auf diese Werte für verschiedene Browser oder Plattformen verlassen. Sie sind beispielsweise mit Sicherheit nicht repräsentativ für Mobilgeräte.

Die automatische Speicherbereinigung wirkt sich nur auf Daten aus, die SourceBuffers hinzugefügt wurden. Es gibt keine Limits für die Menge der Daten, die in JavaScript-Variablen zwischengespeichert werden können. Sie können dieselben Daten bei Bedarf auch an derselben Position wieder anhängen.