Menjadwalkan audio web dengan presisi
Pengantar
Salah satu tantangan terbesar dalam membuat software audio dan musik yang bagus menggunakan platform web adalah mengelola waktu. Bukan seperti “waktu untuk menulis kode”, tetapi seperti waktu jam - salah satu topik yang paling tidak dipahami tentang Audio Web adalah cara menggunakan jam audio dengan benar. Objek AudioContext Audio Web memiliki properti currentTime yang mengekspos jam audio ini.
Khusus untuk aplikasi musik audio web - tidak hanya menulis sequencer dan synthesizer, tetapi juga penggunaan ritme peristiwa audio seperti drum machine, game, dan aplikasi lainnya - sangat penting untuk memiliki pengaturan waktu peristiwa audio yang konsisten dan akurat; tidak hanya memulai dan menghentikan suara, tetapi juga menjadwalkan perubahan pada suara (seperti mengubah frekuensi atau volume). Terkadang memang diinginkan untuk membuat peristiwa yang sedikit diacak waktu - misalnya, dalam demo machine gun di Developing Game Audio with the Web Audio API - namun biasanya, kita ingin memiliki pengaturan waktu yang konsisten dan akurat untuk not balok.
Kami telah menunjukkan cara menjadwalkan catatan menggunakan parameter waktu metode noteOn dan noteOff (sekarang diganti namanya menjadi start dan stop) Web Audio di Memulai Web Audio dan juga di Mengembangkan Audio Game dengan Web Audio API; Namun, kita belum mempelajari secara mendalam skenario yang lebih kompleks, seperti memutar urutan atau ritme musik yang panjang. Untuk mempelajarinya, pertama-tama kita perlu sedikit latar belakang tentang jam.
Waktu Terbaik - Jam Audio Web
Web Audio API mengekspos akses ke clock hardware subsistem audio. Jam ini ditampilkan di objek AudioContext melalui properti .currentTime-nya, sebagai bilangan floating point detik sejak AudioContext dibuat. Hal ini memungkinkan jam ini (selanjutnya disebut “jam audio”) memiliki presisi yang sangat tinggi; jam ini dirancang agar dapat menentukan perataan pada setiap tingkat sampel suara, bahkan dengan frekuensi sampel yang tinggi. Karena ada sekitar 15 digit desimal presisi dalam “double”, meskipun jam audio telah berjalan selama berhari-hari, jam audio tersebut masih memiliki banyak bit yang tersisa untuk mengarah ke sampel tertentu bahkan pada frekuensi sampel yang tinggi.
Jam audio digunakan untuk menjadwalkan parameter dan peristiwa audio di seluruh Web Audio API - tentu saja untuk start() dan stop(), tetapi juga untuk metode set*ValueAtTime() pada AudioParams. Dengan begitu, kami dapat menyiapkan peristiwa audio dengan waktu yang sangat tepat di awal. Sebenarnya, Anda mungkin ingin menyiapkan semuanya di Web Audio sebagai waktu mulai/berhenti. Namun, dalam praktiknya, ada masalah dengan hal tersebut.
Misalnya, lihat cuplikan kode yang dipersingkat ini dari Pengantar Audio Web, yang menyiapkan dua bar pola hi-hat not delapan:
for (var bar = 0; bar < 2; bar++) {
var time = startTime + bar * 8 * eighthNoteTime;
// Play the hi-hat every eighth note.
for (var i = 0; i < 8; ++i) {
playSound(hihat, time + i * eighthNoteTime);
}
Kode ini akan berfungsi dengan baik. Namun, jika Anda ingin mengubah tempo di tengah dua bar tersebut - atau berhenti bermain sebelum dua bar selesai - Anda tidak akan berhasil. (Saya pernah melihat para developer melakukan hal-hal seperti menyisipkan node perolehan antara AudioBufferSourceNodes yang telah dijadwalkan sebelumnya dan output-nya, agar mereka dapat membisukan suara mereka sendiri.)
Singkatnya, karena Anda memerlukan fleksibilitas untuk mengubah tempo atau parameter seperti frekuensi atau gain (atau berhenti menjadwalkan sama sekali), Anda tidak ingin memasukkan terlalu banyak peristiwa audio ke dalam antrean - atau, lebih tepatnya, Anda tidak ingin melihat terlalu jauh ke depan, karena Anda mungkin ingin mengubah penjadwalan tersebut sepenuhnya.
The Worst of Times - the JavaScript Clock
Kita juga memiliki jam JavaScript yang banyak disukai dan banyak disalahgunakan, yang diwakili oleh Date.now() dan setTimeout(). Sisi baik dari jam JavaScript adalah ia memiliki beberapa metode call-me-back-nanti window.setTimeout() dan window.setInterval() yang sangat berguna, yang memungkinkan sistem memanggil kode kita kembali pada waktu tertentu.
Sisi buruk dari jam JavaScript adalah bahwa hal itu tidak terlalu tepat. Sebagai permulaan, Date.now() menampilkan nilai dalam milidetik - bilangan bulat milidetik - sehingga presisi terbaik yang dapat Anda harapkan adalah satu milidetik. Hal ini tidak terlalu buruk dalam beberapa konteks musik - jika not Anda dimulai lebih awal atau terlambat beberapa milidetik, Anda mungkin tidak akan menyadarinya - tetapi meskipun pada kecepatan hardware audio yang relatif rendah yaitu 44,1 kHz, kecepatannya sekitar 44,1 kali lebih lambat untuk digunakan sebagai jam penjadwalan audio. Ingat bahwa menghapus sampel apa pun dapat menyebabkan gangguan audio. Jadi, jika kita menyambungkan sampel, kita mungkin perlu menyusunnya secara berurutan.
Spesifikasi Waktu Resolusi Tinggi yang akan datang sebenarnya memberi kita waktu saat ini dengan presisi yang jauh lebih baik melalui window.performance.now(); bahkan diterapkan (meskipun diawali dengan awalan) di banyak browser saat ini. Hal ini dapat membantu dalam beberapa situasi, meskipun tidak terlalu relevan dengan bagian terburuk dari JavaScript timing API.
Bagian terburuk dari API pengaturan waktu JavaScript adalah meskipun presisi milidetik Date.now() tidak terlalu buruk, callback sebenarnya dari peristiwa timer di JavaScript (melalui window.setTimeout() atau window.setInterval) dapat dengan mudah terdistorsi hingga puluhan milidetik atau lebih oleh tata letak, rendering, pembersihan sampah, dan XMLHTTPRequest serta callback lainnya - singkatnya, oleh sejumlah hal yang terjadi di thread eksekusi utama. Ingat saat saya menyebutkan “peristiwa audio” yang dapat kita jadwalkan menggunakan Web Audio API? Semuanya diproses di thread terpisah - jadi meskipun thread utama terhenti sementara untuk melakukan tata letak yang kompleks atau tugas panjang lainnya, audio akan tetap terjadi tepat pada waktu yang ditentukan - bahkan, meskipun Anda berhenti di titik henti sementara di debugger, thread audio akan terus memutar peristiwa terjadwal.
Menggunakan fungsi setTimeout() JavaScript di Aplikasi Audio
Karena thread utama dapat dengan mudah terhenti selama beberapa milidetik sekaligus, sebaiknya jangan gunakan setTimeout JavaScript untuk langsung mulai memutar peristiwa audio, karena pada kondisi terbaik, catatan Anda akan diaktifkan dalam waktu sekitar satu milidetik dari waktu yang seharusnya, dan pada kondisi terburuk, catatan akan tertunda lebih lama lagi. Yang terburuk, untuk urutan yang ritmis, tidak akan dipicu pada interval yang tepat karena pengaturan waktu akan sensitif terhadap hal lain yang terjadi di thread JavaScript utama.
Untuk mendemonstrasikan hal ini, saya menulis contoh aplikasi metronom “buruk” - yaitu aplikasi yang menggunakan setTimeout secara langsung untuk menjadwalkan catatan - dan juga melakukan banyak tata letak. Buka aplikasi ini, klik “putar”, lalu ubah ukuran jendela dengan cepat saat sedang diputar; Anda akan melihat bahwa waktunya sangat bergetar (Anda dapat mendengar ritme yang tidak konsisten). “Tapi ini dibuat-buat!” kata Anda? Tentu saja - tetapi itu bukan berarti hal itu tidak terjadi di dunia nyata juga. Bahkan antarmuka pengguna yang relatif statis akan mengalami masalah pengaturan waktu di setTimeout karena relayout - misalnya, saya melihat bahwa mengubah ukuran jendela dengan cepat akan menyebabkan pengaturan waktu di WebkitSynth yang sangat baik menjadi tersendat. Sekarang bayangkan apa yang akan terjadi saat Anda mencoba men-scroll skor musik lengkap dengan audio Anda dengan lancar, dan Anda dapat dengan mudah membayangkan bagaimana hal ini akan memengaruhi aplikasi musik yang kompleks di dunia nyata.
Salah satu pertanyaan yang paling sering diajukan adalah “Mengapa saya tidak bisa mendapatkan callback dari peristiwa audio?” Meskipun mungkin ada kegunaan untuk jenis callback ini, callback tersebut tidak akan menyelesaikan masalah tertentu yang sedang dihadapi. Penting untuk memahami bahwa peristiwa tersebut akan diaktifkan di thread JavaScript utama, sehingga akan mengalami semua potensi penundaan yang sama seperti setTimeout; yaitu, peristiwa tersebut dapat tertunda selama beberapa milidetik yang tidak diketahui dan bervariasi dari waktu yang tepat saat dijadwalkan sebelum benar-benar diproses.
Jadi, apa yang dapat kita lakukan? Nah, cara terbaik untuk menangani pengaturan waktu adalah dengan menyiapkan kolaborasi antara timer JavaScript (setTimeout(), setInterval(), atau requestAnimationFrame() - selengkapnya nanti) dan penjadwalan hardware audio.
Memperoleh Waktu yang Tepat dengan Melihat ke Depan
Mari kita kembali ke demo metronom tersebut - sebenarnya, saya menulis versi pertama demo metronom sederhana ini dengan benar untuk menunjukkan teknik penjadwalan kolaboratif ini. (Kode ini juga tersedia di GitHub) Demo ini memutar suara bip (dibuat oleh Osilator) dengan presisi tinggi pada setiap not keenam belas, delapan, atau seperempat, yang mengubah nada bergantung pada ketukan. Alat ini juga memungkinkan Anda mengubah tempo dan interval nada saat diputar, atau menghentikan pemutaran kapan saja - yang merupakan fitur utama untuk sequencer ritme di dunia nyata. Cukup mudah untuk menambahkan kode untuk mengubah suara yang digunakan metronom ini saat sedang berjalan.
Caranya untuk mengizinkan kontrol suhu sekaligus mempertahankan pengaturan waktu yang sangat akurat adalah kolaborasi: timer setTimeout yang diaktifkan setiap beberapa saat, dan menyiapkan penjadwalan Audio Web di masa mendatang untuk setiap catatan. Timer setTimeout pada dasarnya hanya memeriksa apakah ada catatan yang perlu dijadwalkan “segera” berdasarkan tempo saat ini, lalu menjadwalkannya, seperti ini:
Dalam praktiknya, panggilan setTimeout() mungkin tertunda, sehingga waktu panggilan penjadwalan mungkin berubah-ubah (dan condong, bergantung pada cara Anda menggunakan setTimeout) dari waktu ke waktu - meskipun peristiwa dalam contoh ini berjarak sekitar 50 md, sering kali lebih sering dari itu (dan terkadang lebih banyak). Namun, selama setiap panggilan, kami menjadwalkan peristiwa Audio Web tidak hanya untuk nada yang perlu diputar sekarang (misalnya, nada pertama), tetapi juga nada yang perlu diputar antara sekarang dan interval berikutnya.
Bahkan, kita tidak ingin hanya melihat ke depan dengan interval yang tepat antara panggilan setTimeout() - kita juga memerlukan beberapa penjadwalan yang tumpang tindih antara panggilan timer ini dan yang berikutnya, untuk mengakomodasi perilaku thread utama dalam kasus terburuk - yaitu, kasus terburuk dari pengumpulan sampah, tata letak, rendering, atau kode lain yang terjadi pada thread utama yang menunda panggilan timer berikutnya. Kita juga perlu memperhitungkan waktu penjadwalan blok audio - yaitu, jumlah audio yang disimpan sistem operasi dalam buffer pemrosesannya - yang bervariasi di seluruh sistem operasi dan hardware, dari satu digit milidetik rendah hingga sekitar 50 md. Setiap panggilan setTimeout() yang ditampilkan di atas memiliki interval biru yang menunjukkan seluruh rentang waktu saat panggilan tersebut akan mencoba menjadwalkan peristiwa; misalnya, peristiwa audio web keempat yang dijadwalkan dalam diagram di atas mungkin telah diputar "terlambat" jika kita menunggu untuk memutarnya hingga panggilan setTimeout berikutnya terjadi, jika panggilan setTimeout itu hanya beberapa milidetik kemudian. Dalam kehidupan nyata, jitter dalam waktu ini bisa jadi lebih ekstrem dari itu, dan tumpang-tindih ini menjadi semakin penting seiring dengan semakin kompleksnya aplikasi Anda.
Latensi lookahead secara keseluruhan memengaruhi seberapa ketat kontrol tempo (dan kontrol real-time lainnya); interval antara panggilan penjadwalan adalah kompromi antara latensi minimum dan seberapa sering kode Anda memengaruhi prosesor. Seberapa banyak Lookahead yang tumpang tindih dengan waktu mulai interval berikutnya menentukan seberapa tangguh aplikasi Anda di berbagai mesin, dan saat aplikasi menjadi lebih kompleks (dan tata letak serta pembersihan sampah memori mungkin memerlukan waktu lebih lama). Secara umum, agar tahan terhadap mesin dan sistem operasi yang lebih lambat, sebaiknya miliki lookahead keseluruhan yang besar dan interval yang cukup singkat. Anda dapat menyesuaikan untuk memiliki tumpang-tindih yang lebih pendek dan interval yang lebih lama, guna memproses lebih sedikit callback, tetapi pada titik tertentu, Anda mungkin mulai mendengar bahwa latensi yang besar menyebabkan perubahan tempo, dll., tidak segera diterapkan; sebaliknya, jika Anda terlalu mengurangi lookahead, Anda mungkin mulai mendengar beberapa jitter (karena panggilan penjadwalan mungkin harus "mengganti" peristiwa yang seharusnya terjadi di masa lalu).
Diagram pengaturan waktu berikut menunjukkan fungsi kode demo metronom: kode ini memiliki interval setTimeout 25 md, tetapi tumpang tindih yang jauh lebih tangguh: setiap panggilan akan dijadwalkan untuk 100 md berikutnya. Kelemahan dari lookahead yang panjang ini adalah perubahan tempo, dll., akan memerlukan waktu sepersepuluh detik untuk diterapkan; namun, kita jauh lebih tahan terhadap gangguan:
Bahkan, Anda dapat melihat dalam contoh ini bahwa kita mengalami gangguan setTimeout di tengah - kita seharusnya memiliki callback setTimeout pada sekitar 270 md, tetapi tertunda karena alasan tertentu hingga sekitar 320 md - 50 md lebih lambat dari yang seharusnya. Namun, latensi yang besar membuat waktu tetap berjalan lancar, dan kami tidak melewatkan satu pun irama, meskipun kami meningkatkan tempo sebelum itu menjadi memainkan not keenam belas pada 240bpm (di luar tempo drum &bass hardcore!)
Ada kemungkinan juga bahwa setiap panggilan penjadwal mungkin akhirnya menjadwalkan beberapa catatan - mari kita lihat apa yang terjadi jika kita menggunakan interval penjadwalan yang lebih lama (250 md ke depan, berjarak 200 md), dan peningkatan tempo di tengah:
Hal ini menunjukkan bahwa setiap panggilan setTimeout() mungkin akhirnya menjadwalkan beberapa peristiwa audio - faktanya, metronom ini adalah aplikasi satu catatan dalam satu waktu sederhana, tetapi Anda dapat dengan mudah melihat cara kerja pendekatan ini untuk mesin drum (di mana sering ada beberapa catatan simultan) atau sequencer (yang mungkin sering memiliki interval yang tidak teratur antar-catatan).
Dalam praktiknya, Anda perlu menyesuaikan interval penjadwalan dan pandangan ke depan untuk melihat seberapa terpengaruh hal tersebut oleh tata letak, pembersihan sampah memori, dan hal-hal lain yang terjadi di thread eksekusi JavaScript utama, dan untuk menyesuaikan perincian kontrol terhadap tempo, dll. Jika Anda memiliki tata letak yang sangat kompleks yang sering terjadi, misalnya, Anda mungkin ingin membuat tampilan yang lebih besar ke depan. Poin utamanya adalah kita ingin jumlah “penjadwalan di depan” yang kita lakukan cukup besar untuk menghindari penundaan, tetapi tidak terlalu besar sehingga menyebabkan penundaan yang terlihat saat menyesuaikan kontrol tempo. Bahkan kasus di atas memiliki tumpang-tindih yang sangat kecil, sehingga tidak akan sangat tangguh di mesin lambat dengan aplikasi web yang kompleks. Tempat yang baik untuk memulai mungkin adalah waktu "lookahead" 100 md, dengan interval yang ditetapkan ke 25 md. Hal ini mungkin masih menimbulkan masalah pada aplikasi yang kompleks pada komputer dengan banyak latensi sistem audio, yang dalam hal ini Anda harus menghemat waktu; atau, jika Anda memerlukan kontrol yang lebih ketat dengan hilangnya beberapa ketahanan, gunakan pandangan yang lebih pendek.
Kode inti dari proses penjadwalan ada dalam fungsi scheduler() -
while (nextNoteTime < audioContext.currentTime + scheduleAheadTime ) {
scheduleNote( current16thNote, nextNoteTime );
nextNote();
}
Fungsi ini hanya mendapatkan waktu hardware audio saat ini, dan membandingkannya dengan waktu untuk nada berikutnya dalam urutan - sebagian besar waktu* dalam skenario yang tepat ini tidak akan melakukan apa pun (karena tidak ada “nada” metronom yang menunggu untuk dijadwalkan, tetapi jika berhasil, fungsi ini akan menjadwalkan nada tersebut menggunakan Web Audio API, dan melanjutkan ke nada berikutnya.
Fungsi scheduleNote() bertanggung jawab untuk benar-benar menjadwalkan “nada” Audio Web berikutnya yang akan diputar. Dalam hal ini, saya menggunakan osilator untuk mengeluarkan suara bip pada frekuensi berbeda. Anda juga bisa dengan mudah membuat node AudioBufferSource dan menyetel buffer-nya untuk suara drum, atau suara lain yang Anda inginkan.
currentNoteStartTime = time;
// create an oscillator
var osc = audioContext.createOscillator();
osc.connect( audioContext.destination );
if (! (beatNumber % 16) ) // beat 0 == low pitch
osc.frequency.value = 220.0;
else if (beatNumber % 4) // quarter notes = medium pitch
osc.frequency.value = 440.0;
else // other 16th notes = high pitch
osc.frequency.value = 880.0;
osc.start( time );
osc.stop( time + noteLength );
Setelah dijadwalkan dan terhubung, kode ini dapat melupakannya sepenuhnya; oscillator akan dimulai, lalu dihentikan, lalu dikumpulkan sampahnya secara otomatis.
Metode nextNote() bertanggung jawab untuk melanjutkan ke not keenam belas berikutnya - yaitu, menetapkan variabel nextNoteTime dan current16thNote ke not berikutnya:
function nextNote() {
// Advance current note and time by a 16th note...
var secondsPerBeat = 60.0 / tempo; // picks up the CURRENT tempo value!
nextNoteTime += 0.25 * secondsPerBeat; // Add 1/4 of quarter-note beat length to time
current16thNote++; // Advance the beat number, wrap to zero
if (current16thNote == 16) {
current16thNote = 0;
}
}
Ini cukup mudah - meskipun penting untuk dipahami bahwa dalam contoh penjadwalan ini, saya tidak melacak “waktu urutan” - yaitu, waktu sejak awal memulai metronom. Yang harus kita lakukan adalah mengingat kapan kita memainkan not terakhir, dan mencari tahu kapan not berikutnya dijadwalkan untuk diputar. Dengan begitu, kita dapat mengubah tempo (atau berhenti bermain) dengan sangat mudah.
Teknik penjadwalan ini digunakan oleh sejumlah aplikasi audio lainnya di web - misalnya, Web Audio Drum Machine, game Acid Defender yang sangat menyenangkan, dan contoh audio yang lebih mendalam seperti demo Efek Granular.
Belum Lagi
Seperti yang diketahui musisi yang baik, yang dibutuhkan setiap aplikasi audio adalah lebih banyak timer. Perlu disebutkan bahwa cara yang tepat untuk melakukan tampilan visual adalah dengan menggunakan sistem pengaturan waktu KETIGA.
Mengapa, mengapa, mengapa kita memerlukan sistem pengaturan waktu lain? Nah, ini disinkronkan ke tampilan visual - yaitu kecepatan refresh grafis - melalui requestAnimationFrame API. Untuk menggambar kotak dalam contoh metronom, hal ini mungkin tidak terlalu penting, tetapi seiring dengan semakin kompleksnya grafik, semakin penting untuk menggunakan requestAnimationFrame() agar sinkron dengan kecepatan refresh visual - dan sebenarnya sama mudahnya digunakan sejak awal seperti menggunakan setTimeout()! Dengan grafik yang disinkronkan dan sangat kompleks (misalnya, tampilan akurat dari not musik yang padat saat diputar dalam paket notasi musik), requestAnimationFrame() akan memberi Anda sinkronisasi audio dan grafik yang paling lancar dan paling akurat.
Kami melacak ketukan dalam antrean di penjadwal:
notesInQueue.push( { note: beatNumber, time: time } );
Interaksi dengan waktu metronom kita saat ini dapat ditemukan di metode draw(), yang dipanggil (menggunakan requestAnimationFrame) setiap kali sistem grafis siap untuk update:
var currentTime = audioContext.currentTime;
while (notesInQueue.length && notesInQueue[0].time < currentTime) {
currentNote = notesInQueue[0].note;
notesInQueue.splice(0,1); // remove note from queue
}
Sekali lagi, Anda akan melihat bahwa kita memeriksa jam sistem audio - karena itulah yang benar-benar ingin kita sinkronkan, karena sistem audio akan benar-benar memutar not - untuk melihat apakah kita harus menggambar kotak baru atau tidak. Bahkan, kita sama sekali tidak menggunakan stempel waktu requestAnimationFrame, karena kita menggunakan jam sistem audio untuk mengetahui posisi kita saat ini.
Tentu saja, saya bisa saja melewati penggunaan callback setTimeout() sama sekali, dan memasukkan penjadwal catatan saya ke dalam callback requestAnimationFrame - lalu kita akan kembali ke dua timer lagi. Hal ini juga tidak masalah, tetapi penting untuk memahami bahwa requestAnimationFrame hanyalah pengganti untuk setTimeout() dalam hal ini; Anda tetap menginginkan akurasi penjadwalan pengaturan waktu Web Audio untuk catatan yang sebenarnya.
Kesimpulan
Semoga tutorial ini bermanfaat dalam menjelaskan jam, timer, dan cara membuat pengaturan waktu yang tepat ke dalam aplikasi audio web. Teknik yang sama ini dapat diekstrapolasi dengan mudah untuk membuat pemutar urutan, mesin drum, dan lainnya. Sampai jumpa lagi…