Extensions de source multimédia pour l'audio

Dale Curtis
Dale Curtis

Introduction

Les Media Source Extensions (MSE) offrent des commandes avancées pour la mise en mémoire tampon et la lecture des éléments HTML5 <audio> et <video>. Bien qu'ils aient été développés à l'origine pour faciliter le streaming adaptatif dynamique sur HTTP (DASH), nous allons voir ci-dessous comment les utiliser pour l'audio, en particulier pour la lecture sans interruption.

Vous avez probablement déjà écouté un album musical où les chansons s'intercalaient d'une piste à l'autre. Vous en écoutez peut-être un en ce moment même. Les artistes créent ces expériences de lecture sans interruption à la fois comme un choix artistique et comme un artefact de disques vinyles et de CD où l'audio a été écrit en continu. Malheureusement, en raison du fonctionnement des codecs audio modernes tels que MP3 et AAC, cette expérience sonore fluide est souvent perdue aujourd'hui.

Nous verrons en détail pourquoi ci-dessous, mais pour l'instant, commençons par une démonstration. Vous trouverez ci-dessous les 30 premières secondes de l'excellent Sintel, découpé en cinq fichiers MP3 distincts et réassemblé à l'aide de MSE. Les lignes rouges indiquent les lacunes introduites lors de la création (encodage) de chaque fichier MP3. Vous entendrez des glitchs à ces points.

Démo

Beurk ! Ce n'est pas une très bonne expérience, nous pouvons faire mieux. Avec un peu plus de travail, en utilisant exactement les mêmes fichiers MP3 de la démonstration ci-dessus, nous pouvons utiliser la MSE pour éliminer ces lacunes. Dans la démonstration suivante, les lignes vertes indiquent où les fichiers ont été joints et où les blancs ont été supprimés. À partir de la version 38 de Chrome, la lecture se déroule sans accroc.

Démo

Il existe différentes façons de créer des contenus sans lacunes. Pour les besoins de cette démonstration, nous allons nous concentrer sur le type de fichiers qu'un utilisateur normal peut avoir sur lui. Dans ce cas, chaque fichier a été encodé séparément, sans tenir compte des segments audio qui le précèdent ou le suivent.

Configuration de base

Tout d'abord, revenons en arrière et abordons la configuration de base d'une instance MediaSource. Les extensions de source multimédia, comme leur nom l'indique, ne sont que des extensions des éléments multimédias existants. Ci-dessous, nous attribuons un Object URL, qui représente notre instance MediaSource, à l'attribut source d'un élément audio. Tout comme vous le feriez pour une URL standard.

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

Une fois l'objet MediaSource connecté, il effectue une initialisation et déclenche à terme un événement sourceopen. Nous pouvons alors créer un SourceBuffer. Dans l'exemple ci-dessus, nous créons un segment audio/mpeg, capable d'analyser et de décoder nos segments MP3. Plusieurs autres types sont disponibles.

Formes d'ondes anormales

Nous reviendrons sur le code dans quelques instants, mais examinons de plus près le fichier que nous venons d'ajouter, plus précisément à la fin. Vous trouverez ci-dessous un graphique représentant la moyenne des 3 000 derniers échantillons sur les deux canaux pour le canal sintel_0.mp3. Chaque pixel de la ligne rouge est un échantillon à virgule flottante compris dans la plage [-1.0, 1.0].

Fin de sintel_0.mp3

C'est quoi ces échantillons zéro (silencieux) ? Ils sont en fait dus à des artefacts de compression introduits lors de l'encodage. Presque tous les encodeurs introduisent un certain type de marge intérieure. Dans ce cas, LAME a ajouté exactement 576 échantillons de marge intérieure à la fin du fichier.

En plus de la marge intérieure à la fin, une marge intérieure a également été ajoutée au début de chaque fichier. Si nous regardons la piste sintel_1.mp3, nous voyons 576 autres échantillons de marge intérieure à l'avant. La quantité de marge intérieure varie selon l'encodeur et le contenu, mais nous connaissons les valeurs exactes en fonction des metadata incluses dans chaque fichier.

Début de sintel_1.mp3

Début de sintel_1.mp3

Les parties de silence au début et à la fin de chaque fichier sont à l'origine des glitchs entre les segments de la démo précédente. Pour que la lecture fonctionne sans interruption, nous devons supprimer ces parties de silence. Heureusement, cette opération est facile à effectuer avec MediaSource. Nous allons modifier ci-dessous la méthode onAudioLoaded() pour utiliser une fenêtre d'ajout et un décalage d'horodatage afin de supprimer ces silences.

Exemple de code

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

Une onde sans couture

Voyons ce que notre nouveau code a accompli en examinant à nouveau la forme d'onde après avoir appliqué nos fenêtres d'ajout. Vous pouvez voir ci-dessous que la section silencieuse à la fin de sintel_0.mp3 (en rouge) et la section silencieuse au début de sintel_1.mp3 (en bleu) ont été supprimées. La transition entre les segments est donc fluide.

Jointure de sintel_0.mp3 et sintel_1.mp3

Conclusion

Nous avons donc parfaitement assemblé les cinq segments en un seul, et nous sommes arrivés à la fin de notre démonstration. Avant de terminer, vous avez peut-être remarqué que la méthode onAudioLoaded() ne tient pas compte des conteneurs ni des codecs. Cela signifie que toutes ces techniques fonctionneront quel que soit le type de conteneur ou de codec. Ci-dessous, vous pouvez relancer la démo originale du fichier MP4 fragmenté compatible avec DASH au lieu du fichier MP3.

Démo

Pour en savoir plus, consultez les annexes ci-dessous pour en savoir plus sur la création de contenu sans lacunes et l'analyse des métadonnées. Vous pouvez également explorer gapless.js pour examiner de plus près le code sur lequel repose cette démonstration.

Merci de votre attention,

Annexe A: Créer un contenu sans lacunes

Créer du contenu sans interruption peut être difficile à obtenir. Nous allons vous présenter ci-dessous la création du média Sintel utilisé dans cette démonstration. Pour commencer, vous avez besoin d'une copie de la bande-son FLAC sans perte pour Sintel. Pour les versions ultérieures, le certificat SHA1 est fourni ci-dessous. Pour utiliser les outils, vous aurez besoin de FFmpeg, de MP4Box, de LAME et d'une installation OSX avec afconvert.

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

Tout d'abord, nous allons diviser les 31,5 premières secondes de la piste 1-Snow_Fight.flac. Nous souhaitons également ajouter un fondu de 2,5 secondes commençant à 28 secondes à l'ouverture pour éviter tout clic une fois la lecture terminée. La ligne de commande FFmpeg ci-dessous nous permet d'effectuer tout cela et de placer les résultats dans sintel.flac.

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

Nous allons ensuite diviser le fichier en 5 fichiers wave de 6,5 secondes chacun.Il est plus facile d'utiliser le mode wave, car presque tous les encodeurs prennent en charge son ingestion. Là encore, nous pouvons le faire précisément avec FFmpeg, après quoi nous obtenons sintel_0.wav, sintel_1.wav, sintel_2.wav, sintel_3.wav et 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

Ensuite, créons les fichiers MP3. LAME propose plusieurs options pour créer du contenu sans intervalles. Si vous contrôlez le contenu, vous pouvez envisager d'utiliser --nogap avec un encodage par lot de tous les fichiers afin d'éviter toute marge intérieure entre les segments. Toutefois, pour les besoins de cette démonstration, nous souhaitons utiliser cette marge intérieure. Nous allons donc utiliser un encodage standard VBR de haute qualité pour les fichiers wave.

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

C'est tout ce qu'il faut pour créer les fichiers MP3. Passons maintenant à la création des fichiers MP4 fragmentés. Nous suivrons les instructions d'Apple pour créer des contenus multimédias masterisés pour iTunes. Nous allons convertir les fichiers wave en fichiers CAF intermédiaires, conformément aux instructions, avant de les encoder au format AAC dans un conteneur MP4 avec les paramètres recommandés.

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

Nous disposons maintenant de plusieurs fichiers M4A que nous devons fragmenter de manière appropriée avant de pouvoir les utiliser avec MediaSource. Pour les besoins de cet atelier, nous utiliserons une taille de fragment d'une seconde. MP4Box écrit chaque MP4 fragmenté en tant que sintel_#_dashinit.mp4 avec un fichier manifeste MPEG-DASH (sintel_#_dash.mpd) qui peut être supprimé.

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

Et voilà ! Nous avons maintenant fragmenté les fichiers MP4 et MP3 avec les bonnes métadonnées nécessaires à une lecture sans interruption. Reportez-vous à l'annexe B pour plus de détails sur ces métadonnées.

Annexe B: Analyser les métadonnées sans écarts

Tout comme la création de contenu sans intervalles, analyser ces métadonnées peut s'avérer délicat, car il n'existe pas de méthode de stockage standard. Nous allons voir ci-dessous comment les deux encodeurs les plus courants, LAME et iTunes, stockent leurs métadonnées manquantes. Commençons par configurer quelques méthodes d'assistance et un aperçu du ParseGaplessData() utilisé ci-dessus.

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

Nous allons d'abord aborder le format de métadonnées iTunes d'Apple, car il est le plus facile à analyser et à expliquer. Dans les fichiers MP3 et M4A, iTunes (et afconvert) écris une courte section en ASCII, comme ceci:

iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00

Le texte est écrit dans un tag ID3 du conteneur MP3 et dans un atome de métadonnées à l'intérieur du conteneur MP4. Pour les besoins de cet atelier, nous pouvons ignorer le premier jeton 0000000. Les trois jetons suivants sont la marge intérieure avant, la marge intérieure de fin et le nombre total d'échantillons sans marge intérieure. Diviser chacun de ces éléments par le taux d'échantillonnage de l'audio nous donne la durée pour chacun.

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

D'un autre côté, la plupart des encodeurs MP3 Open Source stockent les métadonnées sans intervalles dans un en-tête Xing spécial placé à l'intérieur d'une image MPEG silencieuse (ce dernier est silencieux de sorte que les décodeurs qui ne comprennent pas l'en-tête Xing lisent simplement le silence). Malheureusement, cette balise n'est pas toujours présente et comporte plusieurs champs facultatifs. Pour les besoins de cette démonstration, nous contrôlons le contenu multimédia. Toutefois, dans la pratique, des vérifications supplémentaires seront nécessaires pour savoir quand des métadonnées sans interruption sont effectivement disponibles.

Commençons par analyser le nombre total d'échantillons. Pour plus de simplicité, nous lirons ceci à partir de l'en-tête Xing, mais il pourrait être créé à partir de l'en-tête audio MPEG normal. Les en-têtes Xing peuvent être marqués par une balise Xing ou Info. Exactement 4 octets après ce tag, il y a 32 bits représentant le nombre total de trames dans le fichier ; en multipliant cette valeur par le nombre d'échantillons par trame, nous obtenirons le nombre total d'échantillons dans le fichier.

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

Maintenant que nous disposons du nombre total d'échantillons, nous pouvons passer à la lecture des échantillons de marge intérieure. Selon votre encodeur, il peut être écrit sous une balise LAME ou Lavf imbriquée dans l'en-tête Xing. Exactement 17 octets après cet en-tête, il y a 3 octets qui représentent la marge intérieure de 12 bits chacun.

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

Nous disposons ainsi d'une fonction complète permettant d'analyser la grande majorité du contenu sans intervalles. Les cas limites abondent cependant, il est donc recommandé de faire preuve de prudence avant d'utiliser un code similaire en production.

Annexe C: récupération de mémoire

La mémoire appartenant aux instances SourceBuffer fait l'objet d'une récupération active de mémoire en fonction du type de contenu, des limites spécifiques à la plate-forme et de la position de lecture actuelle. Dans Chrome, la mémoire est d'abord récupérée à partir des tampons déjà lus. Toutefois, si l'utilisation de la mémoire dépasse les limites spécifiques à la plate-forme, elle supprime la mémoire des tampons non lus.

Lorsque la lecture atteint un écart dans la timeline en raison de la récupération de mémoire, un trou peut se produire si l'écart est suffisamment petit ou se figer complètement s'il est trop important. L'expérience utilisateur n'est pas optimale non plus. Il est donc important d'éviter d'ajouter trop de données à la fois et de supprimer manuellement les plages de la timeline du média qui ne sont plus nécessaires.

Vous pouvez supprimer des plages via la méthode remove() sur chaque SourceBuffer. La plage [start, end] est exprimée en secondes. Comme pour appendBuffer(), chaque remove() déclenchera un événement updateend une fois l'opération terminée. Les autres opérations de suppression ou d'ajout ne doivent pas être effectuées tant que l'événement n'a pas été déclenché.

Dans Chrome pour ordinateur, vous pouvez conserver environ 12 Mo de contenu audio et 150 Mo de contenu vidéo en mémoire à la fois. Ne vous fiez pas à ces valeurs pour tous les navigateurs ou plates-formes, car elles ne sont certainement pas représentatives des appareils mobiles.

La récupération de mémoire ne concerne que les données ajoutées à SourceBuffers. La quantité de données que vous pouvez conserver en mémoire tampon dans les variables JavaScript est illimitée. Vous pouvez également ajouter les mêmes données à la même position si nécessaire.