Extensions de source multimédia pour l'audio

Dale Curtis
Dale Curtis

Introduction

Les extensions de source multimédia (MSE) offrent des commandes avancées de mise en mémoire tampon et de lecture pour les é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 écouté un album musical dont les titres se sont étoffés d'un titre à l'autre. Vous êtes peut-être en train d'en écouter un en ce moment. 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 un flux continu. Malheureusement, en raison du fonctionnement des codecs audio modernes tels que MP3 et AAC, cette expérience sonore fluide est souvent perdue de nos jours.

Nous verrons 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 des écarts introduits lors de la création (encodage) de chaque fichier MP3. Vous entendrez des problèmes à ces points.

Données démographiques

Beurk ! Ce n'est pas une expérience positive, 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 MSE pour supprimer ces écarts gênants. 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 est fluide.

Données démographiques

Il existe de nombreuses façons de créer du contenu sans interruptions. Pour les besoins de cette démonstration, nous allons nous concentrer sur les types de fichiers qu'un utilisateur normal peut stocker. Où chaque fichier a été encodé séparément, sans tenir compte des segments audio avant ou après.

Configuration de base

Tout d'abord, revenons en arrière et abordons la configuration de base d'une instance MediaSource. Comme leur nom l'indique, les extensions de source multimédia ne sont que des extensions des éléments multimédias existants. Ci-dessous, nous attribuons un élément Object URL, qui représente notre instance MediaSource, à l'attribut source d'un élément audio, 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 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. Il existe plusieurs autres types.

Formes d'ondes anormales

Nous reviendrons sur le code dans un instant, mais examinons maintenant 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 à partir du canal sintel_0.mp3. Chaque pixel de la ligne rouge est un échantillon à virgule flottante compris dans la plage [-1.0, 1.0].

mp3 manquant

C'est quoi ces échantillons zéro (silencieux) ? Ils sont en réalité dus à des artefacts de compression introduits lors de l'encodage. Presque tous les encodeurs introduisent un 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 jetons un coup d'œil à la piste sintel_1.mp3, nous voyons 576 autres exemples de marge intérieure au début. La quantité de marge intérieure varie selon l'encodeur et le contenu, mais nous connaissons les valeurs exactes en fonction des valeurs metadata incluses dans chaque fichier.

fin vide du fichier mp3

Les sections de silence au début et à la fin de chaque fichier sont à l'origine des problèmes entre les segments de la démonstration précédente. Pour obtenir une lecture sans interruption, nous devons supprimer ces sections de silence. Heureusement, MediaSource est très simple à effectuer. Nous allons modifier la méthode onAudioLoaded() ci-dessous pour utiliser une fenêtre ajoutée et un décalage d'horodatage afin de supprimer ce silence.

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 forme d'onde fluide

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 ainsi fluide.

mp3 mid

Conclusion

Nous avons ainsi assemblé les cinq segments de façon transparente en un seul, et c'est 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. Vous pouvez revoir ci-dessous la démo d'origine au format MP4 fragmenté prêt à l'emploi DASH au lieu du fichier MP3.

Données démographiques

Si vous souhaitez en savoir plus, consultez les annexes ci-dessous pour en savoir plus sur la création de contenu sans interruptions 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éation de contenu sans lacunes

Il peut être difficile de créer du contenu sans interruptions. Nous vous présentons 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 la postérité, le SHA1 est inclus ci-dessous. Pour utiliser les outils, vous aurez besoin de FFmpeg, MP4Box, 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 séparer les 31,5 premières secondes de la piste 1-Snow_Fight.flac. Nous souhaitons également ajouter un fondu à la fin de 2,5 secondes à partir de 28 secondes pour éviter tout clic une fois la lecture terminée. La ligne de commande FFmpeg ci-dessous nous permet d'effectuer toutes ces opérations 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 cinq fichiers wave de 6,5 secondes chacun.Il est plus facile d'utiliser le mode Wave, car presque tous les encodeurs permettent 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

Créons ensuite les fichiers MP3. L'API 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 voulons utiliser cette marge intérieure. Nous allons donc utiliser un encodage VBR standard de haute qualité pour les fichiers de vague.

    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 qui est nécessaire pour créer les fichiers MP3. Voyons maintenant la création des fichiers MP4 fragmentés. Nous suivrons les instructions d'Apple pour créer un contenu multimédia mastérisé 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 interruptions. Reportez-vous à l'annexe B pour en savoir plus sur ces métadonnées.

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

Tout comme la création d'un contenu sans lacunes, l'analyse des métadonnées correspondantes peut s'avérer délicate, car il n'existe pas de méthode de stockage standard. Nous verrons ci-dessous comment les deux encodeurs les plus courants, LAME et iTunes, stockent leurs métadonnées sans interruptions. Commençons par configurer des 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 aborderons d'abord 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) écrivez une courte section en ASCII comme suit:

    iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00

Ceci est écrit dans un tag ID3 dans le 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 chacune d'entre elles par le taux d'échantillonnage de l'audio nous donne la durée pour chacune.

// 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 trame MPEG silencieuse (elle est silencieuse, 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 un certain nombre de champs facultatifs. Pour les besoins de cette démonstration, nous contrôlons les médias, mais dans la pratique, nous devons effectuer des vérifications supplémentaires de la sensibilité afin de savoir à quel moment des métadonnées sans lacunes sont disponibles.

Nous allons d'abord analyser le nombre total d'échantillons. Pour plus de simplicité, nous allons lire ces informations à partir de l'en-tête Xing, mais il peut être créé à partir de l'en-tête audio MPEG normal. Les en-têtes Xing peuvent être marqués à l'aide d'une balise Xing ou Info. Exactement 4 octets après cette balise, 32 bits représentent le nombre total de trames dans le fichier. En multipliant cette valeur par le nombre d'échantillons par trame, nous obtenons 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 du nombre d'échantillons avec marge intérieure. Selon votre encodeur, cela 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, 3 octets représentent respectivement la marge intérieure et la marge intérieure de fin 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 d'une fonction complète permettant d'analyser la grande majorité du contenu sans interruptions. Cependant, les cas limites abondent, c'est pourquoi nous vous recommandons de faire preuve de prudence avant d'utiliser du 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 de mémoire active 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 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 chronologie en raison de la récupération de mémoire, il peut y avoir un problème s'il est suffisamment petit ou se bloquer complètement s'il est trop grand. 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 multimédia qui ne sont plus nécessaires.

Les plages peuvent être supprimées via la méthode remove() sur chaque SourceBuffer, qui accepte une plage [start, end] en secondes. Comme pour appendBuffer(), chaque remove() déclenche un événement updateend une fois l'opération terminée. Les autres opérations de suppression ou d'ajout ne doivent pas être émises tant que l'événement n'a pas été déclenché.

Sur un ordinateur de bureau Chrome, vous pouvez conserver environ 12 mégaoctets de contenu audio et 150 mégaoctets de contenu vidéo en mémoire à la fois. Ne vous fiez pas à ces valeurs dans les différents navigateurs ou plates-formes. Par exemple, elles ne sont certainement pas représentatives des appareils mobiles.

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

Commentaires