Ekstensi Sumber Media untuk Audio

Dale Curtis
Dale Curtis

Pengantar

Media Source Extensions (MSE) memberikan kontrol pemutaran dan buffering yang lebih panjang untuk elemen <audio> dan <video> HTML5. Meskipun awalnya dikembangkan untuk memfasilitasi pemutar video berbasis Dynamic Adaptive Streaming over HTTP (DASH), di bawah ini kami akan melihat cara penggunaannya untuk audio; khususnya untuk pemutaran tanpa jeda.

Anda mungkin pernah mendengarkan album musik dengan lagu yang mengalir mulus di seluruh lagu; Anda bahkan mungkin mendengarkan salah satunya sekarang. Artis menciptakan pengalaman pemutaran tanpa jeda ini sebagai pilihan artistik serta artefak rekaman vinil dan CD dengan audio yang ditulis sebagai satu streaming yang berkelanjutan. Sayangnya, karena cara kerja codec audio modern seperti MP3 dan AAC, pengalaman suara yang lancar ini sering kali hilang saat ini.

Kita akan membahas alasannya di bawah ini, tetapi untuk saat ini mari kita mulai dengan sebuah demonstrasi. Berikut adalah tiga puluh detik pertama Sintel yang sangat 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 di titik ini.

Demo

Ya ampun! Itu bukan pengalaman hebat; kita bisa lebih baik lagi. Dengan sedikit upaya lebih, menggunakan {i>file<i} MP3 yang sama persis di demo di atas, kita dapat menggunakan MSE untuk menghilangkan celah yang mengganggu tersebut. Garis hijau di demo berikutnya menunjukkan di mana {i>file<i} telah digabungkan dan {i>gap<i} dihapus. Di Chrome 38+, fitur ini akan diputar dengan lancar.

Demo

Ada berbagai cara untuk membuat konten tanpa jeda. Untuk tujuan demo ini, kita akan fokus pada jenis file yang mungkin dimiliki pengguna normal. Setiap file telah dienkode secara terpisah tanpa memperhatikan segmen audio sebelum atau sesudahnya.

Penyiapan Dasar

Pertama, mari kita mundur dan bahas penyiapan dasar instance MediaSource. Media Source Extensions, sesuai dengan namanya, hanyalah ekstensi untuk elemen media yang ada. Di bawah ini, kita menetapkan Object URL, yang mewakili instance MediaSource, ke atribut sumber elemen audio; sama seperti Anda akan 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 mengaktifkan peristiwa sourceopen; pada saat itu kita dapat membuat SourceBuffer. Pada contoh di atas, kita membuat audio/mpeg, yang dapat mengurai dan mendekode segmen MP3 kita; tersedia beberapa jenis lain.

Bentuk Gelombang yang Tidak Wajar

Kita akan kembali ke kode sebentar lagi, tetapi sekarang mari kita lihat file yang baru saja kita tambahkan, khususnya di akhir file. Di bawah ini, adalah grafik 3.000 sampel terakhir yang dirata-ratakan di kedua saluran dari lagu sintel_0.mp3. Setiap piksel pada garis merah adalah sampel floating point dalam rentang [-1.0, 1.0].

Akhir dari sintel_0.mp3

Ada apa dengan semua sampel nol (senyap) itu!? Error tersebut sebenarnya disebabkan oleh artefak kompresi yang diperkenalkan selama encoding. Hampir setiap encoder memperkenalkan beberapa jenis padding. Dalam hal ini, LAME menambahkan dengan tepat 576 sampel padding ke bagian akhir file.

Selain padding di bagian akhir, setiap file juga memiliki padding yang ditambahkan di awal. Jika kita melihat ke depan di jalur sintel_1.mp3, kita akan melihat 576 sampel padding lainnya ada di bagian depan. Jumlah padding bervariasi menurut encoder dan konten, tetapi kami mengetahui nilai persisnya berdasarkan metadata yang disertakan dalam setiap file.

Awal dari sintel_1.mp3

Awal dari sintel_1.mp3

Bagian senyap di awal dan akhir setiap file adalah penyebab gangguan antarsegmen pada demo sebelumnya. Untuk mencapai pemutaran tanpa jeda, kita perlu menghapus bagian senyap ini. Untungnya, ini mudah dilakukan dengan MediaSource. Di bawah ini, kita akan mengubah metode onAudioLoaded() untuk menggunakan tambahkan jendela dan offset stempel waktu untuk menghapus senyap ini.

Kode Contoh

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 Lancar

Mari kita lihat apa yang telah dicapai kode baru kita dengan memperhatikan kembali bentuk gelombang setelah kita menerapkan jendela add. Di bawah ini, Anda dapat melihat bahwa bagian senyap di akhir sintel_0.mp3 (berwarna merah) dan bagian senyap di awal sintel_1.mp3 (berwarna biru) telah dihapus; sehingga kami dapat melakukan transisi antar segmen dengan lancar.

Bergabung dari sintel_0.mp3 dan sintel_1.mp3

Kesimpulan

Dengan begitu, kami telah menggabungkan kelima segmen dengan lancar menjadi satu dan selanjutnya mencapai akhir demo. Sebelum kita mulai, Anda mungkin telah mengetahui bahwa metode onAudioLoaded() tidak mempertimbangkan container atau codec. Artinya, semua teknik ini akan berfungsi, apa pun jenis container atau codec. Di bawah ini Anda dapat memutar ulang demo asli MP4 terfragmentasi DASH, bukan MP3.

Demo

Jika Anda ingin mengetahui lebih lanjut, periksa lampiran di bawah ini untuk mempelajari lebih lanjut pembuatan konten tanpa jeda dan penguraian metadata. Anda juga dapat mempelajari gapless.js untuk mempelajari lebih lanjut kode yang mendukung demo ini.

Terima kasih sudah membaca!

Lampiran A: Membuat Konten Tanpa Celah

Membuat konten tanpa jeda mungkin akan sulit untuk dilakukan dengan benar. 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 anak cucu, SHA1 disertakan di bawah ini. Untuk alat ini, Anda memerlukan penginstalan FFmpeg, MP4Box, LAME, dan OSX dengan afconvert.

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

Pertama, kita akan membagi trek 1-Snow_Fight.flac dalam 31,5 detik pertama. Kami juga ingin menambahkan fade out 2,5 detik mulai dari 28 detik untuk menghindari klik setelah pemutaran selesai. Dengan menggunakan command line FFmpeg di bawah, kita dapat menyelesaikan semua ini dan memasukkan 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; cara termudah untuk menggunakan wave karena hampir setiap encoder mendukung penyerapannya. Sekali lagi, kita dapat melakukannya dengan 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 dapat 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 kualitas tinggi untuk 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

Hanya itu 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, kami akan mengonversi file wave menjadi file CAF menengah, sesuai petunjuk, sebelum mengenkodenya sebagai AAC dalam container 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 difragmentasi dengan tepat sebelum dapat digunakan dengan MediaSource. Untuk tujuan ini, kita akan menggunakan ukuran fragmen satu detik. MP4Box akan menulis setiap MP4 yang terfragmentasi sebagai sintel_#_dashinit.mp4 bersama dengan 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. Kami kini memiliki file MP4 dan MP3 yang terfragmentasi dengan metadata yang benar yang diperlukan untuk pemutaran tanpa jeda. Lihat Lampiran B untuk detail selengkapnya tentang tampilan {i>metadata<i} tersebut.

Lampiran B: Mengurai Metadata Tanpa Celah

Sama seperti membuat konten tanpa jeda, mengurai metadata tanpa jeda bisa jadi rumit karena tidak ada metode standar untuk penyimpanan. Di bawah ini kami 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.

Kami akan membahas format metadata iTunes Apple terlebih dahulu karena format ini paling mudah untuk diurai dan dijelaskan. Dalam file MP3 dan M4A, iTunes (dan afconvert) tuliskan bagian pendek dalam ASCII seperti berikut:

iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00

Ini ditulis di dalam tag ID3 dalam kontainer MP3 dan di dalam atom metadata di dalam kontainer MP4. Untuk tujuan ini, kita dapat mengabaikan token 0000000 pertama. Tiga token berikutnya adalah padding depan, padding akhir, dan total jumlah sampel non-padding. Membaginya masing-masing dengan frekuensi sampel audio memberi kami durasi untuk setiap sample tersebut.

// 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 celah dalam header Xing khusus yang ditempatkan di dalam frame MPEG senyap (fitur ini senyap sehingga decoder yang tidak memahami header Xing hanya akan memutar keheningan). Sayangnya, tag ini tidak selalu ada dan memiliki sejumlah kolom opsional. Untuk tujuan demo ini, kami 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 jumlah total sampel. Untuk memudahkan, kita akan membaca ini dari header Xing, tetapi hal 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 {i>frame<i} akan memberi kita total sampel dalam {i>file<i}.

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

Sekarang setelah kita memiliki jumlah total sampel, kita dapat melanjutkan dengan membaca jumlah sampel padding. Bergantung pada encoder Anda, hal ini dapat ditulis di bawah tag LAME atau Lavf yang disarangkan di header Xing. Tepat 17 byte setelah header ini ada 3 byte yang mewakili padding depan dan akhir dalam 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
};
}

Kami memiliki fungsi lengkap untuk menguraikan sebagian besar konten tanpa jeda. Kasus ekstrem pasti berlimpah, jadi hati-hati disarankan sebelum menggunakan kode serupa dalam produksi.

Lampiran C: Tentang Pengumpulan Sampah

Memori milik instance SourceBuffer secara aktif membersihkan sampah memori sesuai dengan jenis konten, batas khusus platform, dan posisi pemutaran saat ini. Di Chrome, memori akan diperoleh kembali terlebih dahulu dari buffer yang sudah 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 diperoleh kembali, pemutaran tersebut dapat mengalami gangguan jika jaraknya cukup kecil atau berhenti sepenuhnya jika celah terlalu besar. Keduanya bukanlah pengalaman pengguna yang bagus, jadi sebaiknya hindari menambahkan terlalu banyak data sekaligus dan menghapus secara manual rentang dari linimasa media yang tidak lagi diperlukan.

Rentang dapat dihapus melalui metode remove() pada setiap SourceBuffer; yang membutuhkan rentang [start, end] dalam hitungan detik. Serupa dengan appendBuffer(), setiap remove() akan mengaktifkan 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 sekaligus dalam memori. Anda tidak boleh mengandalkan nilai ini di seluruh browser atau platform; mis., mereka pasti tidak mewakili perangkat seluler.

Pengumpulan sampah hanya memengaruhi data yang ditambahkan ke SourceBuffers; tidak ada batasan jumlah data yang dapat Anda pertahankan untuk di-buffer dalam variabel JavaScript. Anda juga dapat menambahkan kembali data yang sama di posisi yang sama jika diperlukan.