Ekstensi Sumber Media untuk Audio

Dale Curtis
Dale Curtis

Pengantar

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

Anda mungkin pernah mendengarkan album musik dengan lagu-lagu yang terdengar lancar di semua lagu; bahkan mungkin saat ini Anda sedang mendengarkannya. Para artis menciptakan pengalaman pemutaran tanpa jeda ini sebagai pilihan artistik maupun artefak rekaman vinil dan CD yang audionya ditulis sebagai satu streaming berkelanjutan. Sayangnya, karena cara kerja codec audio modern seperti MP3 dan AAC, pengalaman aural yang lancar ini sering kali hilang saat ini.

Kami akan membahas alasannya di bawah ini secara detail, tetapi untuk saat ini mari kita mulai dengan demonstrasi. Berikut adalah tiga puluh detik pertama dari Sintel istimewa yang dibagi menjadi lima file MP3 terpisah dan disusun ulang menggunakan MSE. Garis merah menunjukkan celah yang muncul selama pembuatan (encoding) dari setiap MP3; Anda akan mendengar gangguan pada titik-titik ini.

Demo

Yaah! Itu bukan pengalaman yang bagus; kita bisa melakukan yang lebih baik. Dengan sedikit usaha, menggunakan file MP3 yang sama persis dalam demo di atas, kita dapat menggunakan MSE untuk menghilangkan celah yang mengganggu tersebut. Garis hijau di demo berikutnya menunjukkan lokasi file telah digabungkan dan bagian kosong dihilangkan. Di Chrome 38+, fitur ini akan diputar dengan lancar.

Demo

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

Penyiapan Dasar

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

Bentuk Gelombang Anomali

Kita akan kembali ke kode beberapa saat lagi, tetapi sekarang mari kita lihat lebih dekat file yang baru saja kita tambahkan, khususnya di bagian akhir. Di bawah ini adalah grafik dari 3.000 sampel terakhir yang diambil rata-ratanya di kedua saluran dari jalur 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 nol sampel itu!? Hal itu 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 di awal. Jika mengintip jalur sintel_1.mp3, kita akan melihat 576 sampel padding lain ada di bagian depan. Jumlah padding bervariasi berdasarkan encoder dan konten, tetapi kami tahu nilai tepatnya berdasarkan metadata yang disertakan dalam setiap file.

Awal dari sintel_1.mp3

Awal dari sintel_1.mp3

Bagian keheningan di awal dan akhir setiap file adalah penyebab gangguan di antara segmen dalam 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 jendela tambahkan dan offset stempel waktu untuk menghapus keheningan 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 Lancar

Mari kita lihat apa yang telah dicapai kode baru kita dengan memperhatikan kembali bentuk gelombang setelah menerapkan jendela tambahan. 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 memberikan transisi yang mulus antarsegmen.

Bergabung dari sintel_0.mp3 dan sintel_1.mp3

Kesimpulan

Dengan demikian, kita telah menggabungkan kelima segmen dengan lancar menjadi satu, dan setelah itu mencapai akhir demo. Sebelum melanjutkan, Anda mungkin telah melihat bahwa metode onAudioLoaded() kita tidak mempertimbangkan container atau codec. Artinya, semua teknik ini akan berfungsi terlepas dari jenis penampung atau codec. Di bawah ini Anda dapat memutar ulang demo asli MP4 terfragmentasi yang siap digunakan DASH, bukan MP3.

Demo

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

Terima kasih sudah membaca.

Lampiran A: Membuat Konten Tanpa Gap

Membuat konten tanpa jeda mungkin sulit 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 masa mendatang, SHA1 disertakan di bawah ini. Untuk alat, 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 selama 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 yang masing-masing berdurasi 6,5 detik.Cara yang paling mudah adalah menggunakan wave karena hampir setiap encoder mendukung proses transfernya. Sekali lagi, kita dapat melakukannya secara tepat 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 {i>file<i} MP3. LAME memiliki beberapa opsi untuk membuat konten tanpa jeda. Jika Anda yang mengontrol konten, Anda dapat mempertimbangkan untuk menggunakan --nogap dengan encoding batch semua file untuk menghindari padding di antara segmen sepenuhnya. Namun, untuk tujuan demo ini, kita ingin padding tersebut sehingga kita akan menggunakan encoding VBR standar kualitas 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 semua yang diperlukan untuk membuat {i>file<i} MP3. Sekarang mari kita bahas pembuatan file MP4 yang terfragmentasi. Kita akan mengikuti petunjuk dari Apple untuk membuat media dengan master untuk iTunes. Di bawah ini, kita akan mengonversi file wave menjadi file CAF menengah, 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 kita fragment 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 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 sekarang memiliki file MP4 dan MP3 terfragmentasi dengan metadata benar yang diperlukan untuk pemutaran tanpa jeda. Lihat Lampiran B untuk penjelasan lebih lanjut tentang tampilan {i>metadata<i}.

Lampiran B: Mengurai Metadata Tanpa Gap

Sama seperti membuat konten tanpa jeda, mengurai metadata tanpa jeda bisa jadi hal yang rumit karena tidak ada metode standar untuk penyimpanan. Di bawah ini kita akan membahas bagaimana dua encoder paling umum, LAME dan iTunes, menyimpan metadata tanpa jeda. Mari kita mulai dengan menyiapkan beberapa metode bantuan dan garis batas 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 itu adalah yang paling mudah untuk diurai dan dijelaskan. Dalam file MP3 dan M4A iTunes (dan afconvert) tuliskan bagian pendek dalam ASCII seperti:

iTunSMPB[ 26 bytes ]0000000 00000840 000001C0 0000000000046E00

Ini ditulis di dalam tag ID3 dalam penampung MP3 dan di dalam atom metadata di dalam penampung MP4. Untuk tujuan ini, kita dapat mengabaikan token 0000000 pertama. Tiga token berikutnya adalah padding depan, padding akhir, dan jumlah sampel total non-padding. Membagi setiap jenis ini dengan frekuensi sampel audio akan memberikan durasi untuk setiap jenis audio 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 gap dalam header Xing khusus yang ditempatkan di dalam frame MPEG senyap (ini senyap sehingga decoder yang tidak memahami header Xing hanya akan memutar diam). 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 sampel total. Untuk memudahkan, kita akan membaca ini dari header Xing, tetapi dapat dibuat dari header audio MPEG biasa. 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.

Sekarang setelah memiliki jumlah total sampel, kita dapat melanjutkan dengan membaca jumlah contoh padding. Bergantung pada encoder Anda, hal ini dapat ditulis dengan tag LAME atau Lavf yang disarangkan di header Xing. Tepat 17 byte setelah header ini, ada 3 byte yang masing-masing mewakili padding depan dan akhir dalam 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, kami memiliki fungsi lengkap untuk mengurai sebagian besar konten tanpa jeda. Namun, kasus ekstrem pasti melimpah, jadi Anda disarankan untuk berhati-hati sebelum menggunakan kode serupa dalam produksi.

Lampiran C: Pengumpulan Sampah

Memori milik instance SourceBuffer secara aktif dikumpulkan sampah sesuai dengan jenis konten, batas khusus platform, dan posisi pemutaran saat ini. Di Chrome, memori akan diklaim kembali dari buffer yang telah diputar terlebih dahulu. Namun, jika penggunaan memori melebihi batas khusus platform, memori dari buffer yang belum diputar akan dihapus.

Saat pemutaran mencapai celah dalam linimasa karena memori yang diperoleh kembali, pemutaran dapat mengalami gangguan jika jaraknya cukup kecil atau berhenti sepenuhnya jika jaraknya terlalu besar. Kedua hal ini bukan pengalaman pengguna yang baik. Oleh karena itu, sebaiknya hindari menambahkan terlalu banyak data sekaligus dan hapus rentang dari linimasa media secara manual jika tidak diperlukan lagi.

Rentang dapat dihapus melalui metode remove() di setiap SourceBuffer; yang memerlukan 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 di memori sekaligus. Anda tidak boleh mengandalkan nilai ini di seluruh browser atau platform; mis., nilai ini pasti tidak mewakili perangkat seluler.

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