Pengantar
Media Source Extensions (MSE) memberikan buffering dan kontrol pemutaran yang diperluas untuk elemen <audio>
dan <video>
HTML5. Meskipun awalnya dikembangkan untuk memfasilitasi pemutar video berbasis Streaming Adaptif Dinamis over HTTP (DASH), di bawah ini kita akan melihat cara pemutar tersebut dapat digunakan untuk audio; khususnya untuk pemutaran tanpa jeda.
Anda mungkin pernah mendengarkan album musik yang lagu-lagunya mengalir dengan lancar di seluruh trek; Anda bahkan mungkin sedang mendengarkannya sekarang. Artis menciptakan pengalaman pemutaran tanpa jeda ini sebagai pilihan artistik serta artefak piringan vinil dan CD tempat audio ditulis sebagai satu aliran yang berkelanjutan. Sayangnya, karena cara kerja codec audio modern seperti MP3 dan AAC, pengalaman audio yang lancar ini sering kali hilang saat ini.
Kita akan membahas detail alasannya di bawah, tetapi untuk saat ini, mari kita mulai dengan demonstrasi. Berikut adalah tiga puluh detik pertama dari Sintel yang bagus yang dipotong menjadi lima file MP3 terpisah dan disusun ulang menggunakan MSE. Garis merah menunjukkan celah yang muncul selama pembuatan (encoding) setiap MP3; Anda akan mendengar gangguan pada titik ini.
Ih! Pengalaman itu tidak bagus; kita bisa melakukannya dengan lebih baik. Dengan sedikit lebih banyak pekerjaan, menggunakan file MP3 yang sama persis dalam demo di atas, kita dapat menggunakan MSE untuk menghapus celah yang mengganggu tersebut. Garis hijau dalam demo berikutnya menunjukkan tempat file telah digabungkan dan celah dihapus. Di Chrome 38+, video akan diputar dengan lancar.
Ada berbagai cara untuk membuat konten tanpa jeda. Untuk tujuan demo ini, kita akan berfokus pada jenis file yang mungkin dimiliki pengguna biasa. Setiap file telah dienkode secara terpisah tanpa mempertimbangkan segmen audio sebelum atau sesudahnya.
Penyiapan Dasar
Pertama, mari kita kembali dan bahas penyiapan dasar instance MediaSource
. Ekstensi Sumber Media, seperti namanya, hanyalah ekstensi untuk elemen media yang ada. Di bawah ini, kita menetapkan Object URL
, yang mewakili instance MediaSource
, ke atribut sumber elemen audio; seperti saat Anda menetapkan URL standar.
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);
Setelah terhubung, objek MediaSource
akan melakukan beberapa inisialisasi dan pada akhirnya memicu peristiwa sourceopen
; pada saat itu kita dapat membuat SourceBuffer
. Dalam contoh di atas, kita membuat audio/mpeg
, yang dapat mengurai dan mendekode segmen MP3; ada beberapa jenis lain yang tersedia.
Bentuk Gelombang Anomali
Kita akan kembali ke kodenya nanti, tetapi sekarang mari kita lihat lebih dekat file yang baru saja ditambahkan, khususnya di bagian akhirnya. Di bawah ini adalah grafik 3.000 sampel terakhir yang dirata-ratakan di kedua saluran dari trek sintel_0.mp3
. Setiap piksel pada garis merah adalah sampel floating point dalam rentang [-1.0, 1.0]
.

Ada apa dengan semua sampel nol (bisu) tersebut? Hal ini sebenarnya disebabkan oleh artefak kompresi yang diperkenalkan selama encoding. Hampir setiap encoder memperkenalkan beberapa jenis padding. Dalam hal ini, LAME menambahkan tepat 576 sampel padding ke akhir file.
Selain padding di bagian akhir, setiap file juga memiliki padding yang ditambahkan ke bagian awal. Jika melihat ke depan pada jalur sintel_1.mp3
, kita akan melihat 576 sampel padding lainnya di bagian depan. Jumlah padding bervariasi menurut encoder dan konten, tetapi kita mengetahui nilai yang tepat berdasarkan metadata
yang disertakan dalam setiap file.

Bagian hening di awal dan akhir setiap file adalah penyebab gangguan di antara segmen dalam demo sebelumnya. Untuk mencapai pemutaran tanpa jeda, kita perlu menghapus bagian hening ini. Untungnya, hal ini dapat dilakukan dengan mudah menggunakan MediaSource
. Di bawah ini, kita akan mengubah metode onAudioLoaded()
untuk menggunakan periode tambahan dan offset stempel waktu untuk menghapus periode tanpa aktivitas ini.
Contoh Kode
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);
}
Bentuk Gelombang yang Mulus
Mari kita lihat apa yang telah dicapai kode baru kita dengan melihat kembali bentuk gelombang setelah kita menerapkan periode penambahan. Di bawah ini, Anda dapat melihat bahwa bagian tanpa suara di akhir sintel_0.mp3
(dalam warna merah) dan bagian tanpa suara di awal sintel_1.mp3
(dalam warna biru) telah dihapus; sehingga kita mendapatkan transisi yang lancar di antara segmen.

Kesimpulan
Dengan demikian, kita telah menggabungkan kelima segmen menjadi satu dengan lancar dan telah mencapai akhir demo. Sebelum melanjutkan, Anda mungkin telah memperhatikan bahwa metode onAudioLoaded()
kami tidak mempertimbangkan penampung atau codec. Artinya, semua teknik ini akan berfungsi terlepas dari jenis penampung atau codec. Di bawah ini, Anda dapat memutar ulang demo asli MP4 yang terfragmentasi dan siap DASH, bukan MP3.
Jika Anda ingin mengetahui lebih lanjut, lihat lampiran di bawah untuk melihat lebih dalam pembuatan konten tanpa jeda dan penguraian metadata. Anda juga dapat menjelajahi gapless.js
untuk melihat lebih dekat kode yang mendukung demo ini.
Terima kasih telah membaca.
Lampiran A: Membuat Konten yang Tidak Terputus
Membuat konten yang tidak terputus-putus bisa jadi sulit. Di bawah ini, kita akan membahas pembuatan media Sintel yang digunakan dalam demo ini. Untuk memulai, Anda memerlukan salinan soundtrack FLAC lossless untuk Sintel; untuk generasi mendatang, SHA1 disertakan di bawah. Untuk alat, Anda memerlukan FFmpeg, MP4Box, LAME, dan penginstalan OSX dengan afconvert.
unzip Jan_Morgenstern-Sintel-FLAC.zip
sha1sum 1-Snow_Fight.flac
# 0535ca207ccba70d538f7324916a3f1a3d550194 1-Snow_Fight.flac
Pertama, kita akan membagi 31,5 detik pertama trek 1-Snow_Fight.flac
. Kita juga ingin menambahkan durasi memudar selama 2,5 detik mulai dari 28 detik untuk menghindari klik setelah pemutaran selesai. Dengan menggunakan command line FFmpeg di bawah, kita dapat melakukan semua ini dan menempatkan hasilnya di sintel.flac
.
ffmpeg -i 1-Snow_Fight.flac -t 31.5 -af "afade=t=out:st=28:d=2.5" sintel.flac
Selanjutnya, kita akan membagi file menjadi 5 file wave dengan durasi masing-masing 6,5 detik; wave paling mudah digunakan karena hampir setiap encoder mendukung penyerapannya. Sekali lagi, kita dapat melakukannya dengan tepat menggunakan FFmpeg, setelah itu kita akan memiliki: sintel_0.wav
, sintel_1.wav
, sintel_2.wav
, sintel_3.wav
, dan 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
Selanjutnya, mari kita buat file MP3. LAME memiliki beberapa opsi untuk membuat konten tanpa jeda. Jika Anda mengontrol konten, sebaiknya gunakan --nogap
dengan encoding batch semua file untuk menghindari padding di antara segmen. Namun, untuk tujuan demo ini, kita menginginkan padding tersebut sehingga kita akan menggunakan encoding VBR standar berkualitas tinggi dari file 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
Itulah yang diperlukan untuk membuat file MP3. Sekarang, mari kita bahas pembuatan file MP4 yang terfragmentasi. Kita akan mengikuti petunjuk Apple untuk membuat media yang dimaster untuk iTunes. Di bawah ini, kita akan mengonversi file wave menjadi file CAF perantara, sesuai petunjuk, sebelum mengenkodenya sebagai AAC dalam penampung MP4 menggunakan parameter yang direkomendasikan.
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
Sekarang kita memiliki beberapa file M4A yang perlu difragmen dengan tepat sebelum dapat digunakan dengan MediaSource
. Untuk tujuan kita, kita akan menggunakan ukuran fragmen satu detik. MP4Box akan menulis setiap MP4 yang terfragmentasi sebagai sintel_#_dashinit.mp4
beserta manifes MPEG-DASH (sintel_#_dash.mpd
) yang dapat dihapus.
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
Selesai. Sekarang kita memiliki file MP4 dan MP3 yang terfragmentasi dengan metadata yang benar yang diperlukan untuk pemutaran tanpa jeda. Lihat Lampiran B untuk mengetahui detail selengkapnya tentang tampilan metadata tersebut.
Lampiran B: Mengurai Metadata Tanpa Jeda
Sama seperti membuat konten tanpa jeda, mengurai metadata tanpa jeda bisa jadi rumit karena tidak ada metode standar untuk penyimpanan. Di bawah ini, kita akan membahas cara dua encoder yang paling umum, LAME dan iTunes, menyimpan metadata tanpa jeda. Mari kita mulai dengan menyiapkan beberapa metode bantuan dan garis besar untuk ParseGaplessData()
yang digunakan di atas.
// 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.
Kita akan membahas format metadata iTunes Apple terlebih dahulu karena format ini paling mudah diuraikan dan dijelaskan. Dalam file MP3 dan M4A, iTunes (dan afconvert) menulis bagian singkat dalam ASCII seperti ini:
iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00
Hal ini ditulis di dalam tag ID3 dalam penampung MP3 dan dalam atom metadata di dalam penampung MP4. Untuk tujuan kita, kita dapat mengabaikan token 0000000
pertama. Tiga token berikutnya adalah padding depan, padding akhir, dan total jumlah sampel non-padding. Dengan membagi setiap sampel dengan frekuensi sampel audio, kita akan mendapatkan durasi untuk setiap sampel.
// 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);
}
Di sisi lain, sebagian besar encoder MP3 open source akan menyimpan metadata tanpa jeda dalam header Xing khusus yang ditempatkan di dalam frame MPEG tanpa suara (tanpa suara sehingga dekoder yang tidak memahami header Xing hanya akan memutar tanpa suara). Sayangnya, tag ini tidak selalu ada dan memiliki sejumlah kolom opsional. Untuk tujuan demo ini, kita memiliki kontrol atas media, tetapi dalam praktiknya, beberapa pemeriksaan tambahan akan diperlukan untuk mengetahui kapan metadata tanpa jeda benar-benar tersedia.
Pertama, kita akan mengurai total jumlah sampel. Untuk mempermudah, kita akan membacanya dari header Xing, tetapi header ini dapat dibuat dari header audio MPEG normal. Header Xing dapat ditandai dengan tag Xing
atau Info
. Tepat 4 byte setelah tag ini, ada 32-bit yang mewakili jumlah total frame dalam file; mengalikan nilai ini dengan jumlah sampel per frame akan memberi kita total sampel dalam file.
// 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.
Setelah memiliki jumlah total sampel, kita dapat melanjutkan untuk membaca jumlah sampel padding. Bergantung pada encoder Anda, ini dapat ditulis di bawah tag LAME atau Lavf yang disusun bertingkat dalam header Xing. Tepat 17 byte setelah header ini, ada 3 byte yang mewakili padding depan dan akhir masing-masing 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
};
}
Dengan demikian, kita memiliki fungsi lengkap untuk mengurai sebagian besar konten tanpa jeda. Namun, kasus ekstrem pasti banyak, jadi sebaiknya berhati-hatilah sebelum menggunakan kode serupa dalam produksi.
Lampiran C: Tentang Pengumpulan Sampah
Memori yang termasuk dalam instance SourceBuffer
secara aktif dibersihkan sampahnya sesuai dengan jenis konten, batas khusus platform, dan posisi pemutaran saat ini. Di Chrome, memori akan diambil kembali terlebih dahulu dari buffering yang telah diputar. Namun, jika penggunaan memori melebihi batas khusus platform, memori akan dihapus dari buffer yang tidak diputar.
Saat pemutaran mencapai celah dalam linimasa karena memori yang diklaim kembali, pemutaran dapat mengalami gangguan jika celah cukup kecil atau terhenti sepenuhnya jika celah terlalu besar. Keduanya bukan pengalaman pengguna yang baik, jadi sebaiknya jangan menambahkan terlalu banyak data sekaligus dan hapus rentang dari linimasa media yang tidak lagi diperlukan secara manual.
Rentang dapat dihapus melalui metode remove()
di setiap SourceBuffer
; yang memerlukan rentang [start, end]
dalam detik. Serupa dengan appendBuffer()
, setiap remove()
akan memicu peristiwa updateend
setelah selesai. Penghapusan atau penambahan lainnya tidak boleh dikeluarkan hingga peristiwa diaktifkan.
Di Chrome desktop, Anda dapat menyimpan sekitar 12 megabyte konten audio dan 150 megabyte konten video dalam memori sekaligus. Anda tidak boleh mengandalkan nilai ini di seluruh browser atau platform; misalnya, nilai ini jelas tidak mewakili perangkat seluler.
Pengumpulan sampah hanya memengaruhi data yang ditambahkan ke SourceBuffers
; tidak ada batasan jumlah data yang dapat Anda simpan dalam buffering di variabel JavaScript. Anda juga dapat menambahkan ulang data yang sama di posisi yang sama jika perlu.