Introduction
Les extensions de source multimédia (MSE) offrent un contrôle étendu de la mise en mémoire tampon et de la lecture pour les éléments HTML5 <audio>
et <video>
. Bien qu'ils aient été développés à l'origine pour faciliter les lecteurs vidéo basés sur le streaming adaptatif dynamique sur HTTP (DASH), nous verrons ci-dessous comment ils peuvent être utilisés pour l'audio, en particulier pour la lecture sans interruption.
Vous avez probablement déjà écouté un album musical dont les titres se succédaient sans interruption. Vous en écoutez peut-être un en ce moment même. Les artistes créent ces expériences de lecture sans coupure à la fois par choix artistique et en raison de l'héritage des disques vinyles et des CD, où l'audio était écrit en flux continu. Malheureusement, en raison du fonctionnement des codecs audio modernes tels que le MP3 et l'AAC, cette expérience auditive fluide est souvent perdue aujourd'hui.
Nous allons expliquer pourquoi ci-dessous, mais pour l'instant, commençons par une démonstration. Vous trouverez ci-dessous les trente premières secondes de l'excellent Sintel découpées en cinq fichiers MP3 distincts et réassemblées à l'aide de MSE. Les lignes rouges indiquent les écarts introduits lors de la création (encodage) de chaque MP3. Vous entendrez des glitchs à ces endroits.
Beurk ! Ce n'est pas une bonne expérience. Nous pouvons faire mieux. Avec un peu plus d'effort, en utilisant les mêmes fichiers MP3 que dans la démonstration ci-dessus, nous pouvons utiliser MSE pour supprimer ces silences gênants. Les lignes vertes de la démonstration suivante indiquent les endroits où les fichiers ont été joints et les lacunes supprimées. Sous Chrome 38 ou version ultérieure, la lecture se fera sans problème.
Il existe de nombreuses façons de créer des contenus sans coupure. Pour les besoins de cette démonstration, nous allons nous concentrer sur les types de fichiers qu'un utilisateur normal peut avoir sous la main. 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
Commençons par revenir en arrière et examiner 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 Object URL
, représentant 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 éventuellement un événement sourceopen
. À ce stade, nous pouvons créer un SourceBuffer
. Dans l'exemple ci-dessus, nous en créons un de type audio/mpeg
, qui peut analyser et décoder nos segments MP3. Plusieurs autres types sont disponibles.
Formes d'onde anormales
Nous reviendrons sur le code dans un instant, mais examinons maintenant de plus près le fichier que nous venons d'ajouter, en particulier à la fin. Vous trouverez ci-dessous un graphique des 3 000 derniers échantillons, calculés en moyenne sur les deux canaux du canal sintel_0.mp3
. Chaque pixel de la ligne rouge est un échantillon à virgule flottante compris dans la plage [-1.0, 1.0]
.

Pourquoi tous ces échantillons nuls (silencieux) ? Il s'agit en fait d'artefacts de compression introduits lors de l'encodage. Presque tous les encodeurs introduisent un type de remplissage. Dans ce cas, LAME a ajouté exactement 576 échantillons de remplissage à 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 verrons que 576 échantillons de remplissage existent à l'avant. La quantité de remplissage varie selon l'encodeur et le contenu, mais nous connaissons les valeurs exactes en fonction de metadata
inclus dans chaque fichier.

Ce sont les sections de silence au début et à la fin de chaque fichier qui provoquent les glitches entre les segments dans la démonstration précédente. Pour obtenir une lecture sans coupure, nous devons supprimer ces sections de silence. Heureusement, cela est facile à faire avec MediaSource
. Ci-dessous, nous allons modifier notre méthode onAudioLoaded()
pour utiliser une fenêtre d'ajout et un décalage de code temporel 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);
}
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, ce qui nous permet d'obtenir une transition fluide entre les segments.

Conclusion
Nous avons ainsi assemblé les cinq segments en un seul et nous sommes arrivés à la fin de notre démonstration. Avant de partir, vous avez peut-être remarqué que notre 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 ci-dessous revoir la version MP4 fragmentée compatible avec DASH de la démo d'origine au lieu du MP3.
Pour en savoir plus, consultez les annexes ci-dessous pour en savoir plus sur la création de contenus sans coupure et l'analyse des métadonnées. Vous pouvez également explorer gapless.js
pour examiner plus en détail le code qui alimente cette démonstration.
Merci de votre attention,
Annexe A: Créer du contenu sans interruption
Créer des contenus sans lacunes peut s'avérer difficile. Vous allez découvrir ci-dessous comment créer les éléments multimédias Sintel utilisés dans cette démonstration. Pour commencer, vous aurez besoin d'une copie de la bande-son FLAC sans perte de Sintel. Pour la postérité, la valeur SHA1 est incluse ci-dessous. Vous aurez besoin des outils 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 diviser les 31,5 premières secondes de la piste 1-Snow_Fight.flac
. Nous souhaitons également ajouter un fondu 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 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
Ensuite, nous allons diviser le fichier en cinq fichiers Wave de 6,5 secondes chacun.Il est plus facile d'utiliser Wave, car presque tous les encodeurs acceptent son ingestion. Encore une fois, nous pouvons le faire précisément avec FFmpeg, après quoi nous aurons: 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. LAME propose plusieurs options pour créer des contenus sans coupure. Si vous contrôlez le contenu, vous pouvez envisager d'utiliser --nogap
avec un encodage par lot de tous les fichiers pour éviter tout remplissage entre les segments. Pour les besoins de cette démonstration, nous voulons ce remplissage. Nous allons donc utiliser un encodage VBR standard 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 dont vous avez besoin pour créer les fichiers MP3. Voyons maintenant comment créer des fichiers MP4 fragmentés. Nous allons suivre les instructions d'Apple pour créer des contenus multimédias masterisés pour iTunes. Ci-dessous, nous allons convertir les fichiers Wave en fichiers CAF intermédiaires, conformément aux instructions, avant de les encoder en AAC dans un conteneur MP4 à l'aide des 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 avons maintenant plusieurs fichiers M4A que nous devons fragmenter de manière appropriée avant de pouvoir les utiliser avec MediaSource
. Pour nos besoins, nous utiliserons une taille de fragment d'une seconde. MP4Box écrira chaque MP4 fragmenté en tant que sintel_#_dashinit.mp4
, ainsi qu'un fichier manifeste MPEG-DASH (sintel_#_dash.mpd
) pouvant ê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 disposons désormais de fichiers MP4 et MP3 fragmentés avec les métadonnées appropriées pour la lecture sans coupure. Pour en savoir plus sur l'apparence de ces métadonnées, consultez l'annexe B.
Annexe B: Analyse des métadonnées sans coupure
Tout comme la création de contenu sans coupure, l'analyse des métadonnées sans coupure peut s'avérer délicate, car il n'existe pas de méthode standard de stockage. Vous trouverez ci-dessous comment les deux encodeurs les plus courants, LAME et iTunes, stockent leurs métadonnées sans coupure. Commençons par configurer des méthodes d'assistance et un plan pour le 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) écrit une courte section en ASCII comme suit:
iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00
Cette information est écrite dans une balise ID3 dans le conteneur MP3 et dans un atome de métadonnées dans le conteneur MP4. Pour nos besoins, nous pouvons ignorer le premier jeton 0000000
. Les trois jetons suivants correspondent à la marge intérieure, à la marge extérieure et au nombre total d'échantillons sans marge. En divisant chacun de ces éléments par la fréquence d'échantillonnage de l'audio, vous obtenez la durée de 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);
}
À l'inverse, la plupart des encodeurs MP3 Open Source stockent les métadonnées sans coupure dans un en-tête Xing spécial placé dans un frame MPEG silencieux (il est silencieux afin que les décodeurs qui ne comprennent pas l'en-tête Xing ne diffusent que du 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 le contenu multimédia, mais en pratique, des vérifications supplémentaires seront nécessaires pour savoir quand les métadonnées sans coupure sont réellement disponibles.
Nous allons d'abord analyser le nombre total d'échantillons. Pour plus de simplicité, nous allons le lire à partir de l'en-tête Xing, mais il peut être construit à partir de l'en-tête audio MPEG standard. Les en-têtes Xing peuvent être marqués par une balise Xing
ou Info
. Juste 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, vous obtiendrez 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 connaissons le nombre total d'échantillons, nous pouvons passer à la lecture du nombre d'échantillons de remplissage. En fonction de 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, trois octets représentent la marge avant et la marge de fin, chacune de 12 bits.
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 pour analyser la grande majorité des contenus sans coupure. Cependant, les cas particuliers sont nombreux. Il est donc recommandé de faire preuve de prudence avant d'utiliser un code similaire en production.
Annexe C: À propos de la récupération de mémoire
La mémoire appartenant aux instances SourceBuffer
est activement collectée 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, la mémoire sera supprimée des tampons non lus.
Lorsque la lecture atteint un écart dans la chronologie en raison de la récupération de mémoire, elle peut présenter des erreurs si l'écart est suffisamment petit ou s'arrêter complètement si l'écart est trop important. Ce n'est pas une bonne expérience utilisateur. Il est donc important d'éviter d'ajouter trop de données à la fois et de supprimer manuellement les plages de la chronologie multimédia qui ne sont plus nécessaires.
Vous pouvez supprimer des plages à l'aide de la méthode remove()
sur chaque SourceBuffer
, qui prend une plage [start, end]
en secondes. Comme pour appendBuffer()
, chaque remove()
déclenche un événement updateend
une fois terminé. Les autres suppressions ou ajouts ne doivent pas être effectués avant le déclenchement de l'événement.
Dans Chrome pour ordinateur, vous pouvez stocker environ 12 mégaoctets de contenu audio et 150 mégaoctets de contenu vidéo en mémoire à la fois. Vous ne devez pas vous fier à ces valeurs pour les navigateurs ou les 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
. Il n'y a aucune limite à la quantité de données que vous pouvez conserver en mémoire tampon dans les variables JavaScript. Si nécessaire, vous pouvez également ajouter à nouveau les mêmes données à la même position.