Kisah tentang dua jam

Menjadwalkan audio web dengan presisi

Chris Wilson
Chris Wilson

Pengantar

Salah satu tantangan terbesar dalam membangun perangkat lunak audio dan musik yang hebat menggunakan platform web adalah mengelola waktu. Tidak seperti dalam “waktu untuk menulis kode”, tetapi seperti dalam waktu jam - salah satu topik yang paling tidak dipahami tentang Audio Web adalah cara bekerja dengan jam audio dengan benar. Objek AudioContext Web Audio memiliki properti currentTime yang mengekspos jam audio ini.

Khususnya untuk aplikasi musik audio web - tidak hanya menulis sequencer dan synthesizer, tetapi juga penggunaan peristiwa audio yang ritmis seperti mesin drum, game, dan aplikasi lainnya - sangat penting untuk memiliki pengaturan waktu peristiwa audio yang konsisten dan tepat; tidak hanya memulai dan menghentikan suara, tetapi juga menjadwalkan perubahan suara (seperti mengubah frekuensi atau volume). Kadang-kadang diinginkan untuk memiliki peristiwa yang sedikit diacak waktu - misalnya, dalam demo senapan mesin dalam Mengembangkan Audio Game dengan API Audio Web - tetapi 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 Web Audio (sekarang diganti namanya mulai dan berhenti) di Memulai Audio Web dan juga dalam Mengembangkan Audio Game dengan Web Audio API; namun, kami belum mengeksplorasi lebih dalam skenario yang lebih kompleks, seperti memutar rangkaian musik atau ritme yang panjang. Untuk mempelajarinya, pertama-tama kita perlu sedikit latar belakang tentang jam.

The Best of Times - Jam Audio Web

Web Audio API mengekspos akses ke jam hardware subsistem audio. Jam ini diekspos pada objek AudioContext melalui properti .currentTime-nya, sebagai jumlah floating point detik sejak AudioContext dibuat. Hal ini memungkinkan jam ini (selanjutnya disebut “jam audio”) menjadi presisi yang sangat tinggi; jam ini dirancang agar dapat menentukan penyelarasan pada tingkat sampel suara individual, bahkan dengan frekuensi sampel yang tinggi. Karena ada sekitar 15 digit desimal presisi dalam "ganda", bahkan jika jam audio telah berjalan selama berhari-hari, jam audio seharusnya 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. Hal ini memungkinkan kita menyiapkan peristiwa audio dengan waktu yang sangat tepat terlebih dahulu. Bahkan, ada baiknya untuk mengatur segala sesuatu di Audio Web sebagai waktu mulai/berhenti - namun, pada praktiknya, ada masalah dengan hal itu.

Misalnya, lihat cuplikan kode yang dikurangi dari Pengantar Audio Web kami, yang membentuk dua batang pola hi-hat kedelapan:

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 batang itu - atau berhenti bermain sebelum dua batang itu naik - Anda belum beruntung. (Saya pernah melihat developer melakukan hal-hal seperti menyisipkan node perolehan di antara AudioBufferSourceNodes yang telah dijadwalkan sebelumnya dan outputnya, agar mereka dapat membisukan suara mereka sendiri.)

Singkatnya, karena Anda memerlukan fleksibilitas untuk mengubah tempo atau parameter seperti frekuensi atau penguatan (atau menghentikan penjadwalan sepenuhnya), sebaiknya jangan masukkan 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.

Waktu Terburuk - Jam JavaScript

Kita juga memiliki jam JavaScript yang sangat disukai dan banyak dirusak, yang diwakili oleh Date.now() dan setTimeout(). Sisi baik dari jam JavaScript adalah bahwa jam ini memiliki beberapa metode call-me-back-linked 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 jam ini tidak terlalu tepat. Sebagai permulaan, Date.now() mengembalikan nilai dalam milidetik - bilangan bulat dalam milidetik - sehingga presisi terbaik yang bisa Anda harapkan adalah satu milidetik. Ini tidak terlalu buruk dalam beberapa konteks musik - jika catatan Anda dimulai satu milidetik lebih awal atau terlambat, Anda mungkin tidak menyadarinya - tetapi bahkan pada tingkat perangkat keras audio yang relatif rendah 44.1kHz, sekitar 44,1 kali terlalu lambat untuk digunakan sebagai jam penjadwalan audio. Perlu diingat bahwa menjatuhkan sampel sama sekali dapat menyebabkan gangguan audio - jadi jika kita merangkai sampel, kita mungkin membutuhkan sampel yang berurutan dengan tepat.

Spesifikasi Waktu Resolusi Tinggi yang akan datang sebenarnya memberikan ketepatan waktu saat ini yang jauh lebih baik melalui window.performance.now(); bahkan diimplementasikan (meskipun diberi awalan) di banyak browser saat ini. Ini bisa membantu dalam beberapa situasi, walaupun tidak terlalu relevan dengan bagian terburuk dari API pengaturan waktu JavaScript.

Bagian terburuk dari API pengaturan waktu JavaScript adalah meskipun presisi milidetik Date.now() tidak terdengar terlalu buruk untuk digunakan, callback peristiwa timer yang sebenarnya di JavaScript (melalui window.setTimeout() atau window.setInterval) dapat dengan mudah didistorsi oleh puluhan milidetik atau lebih berdasarkan tata letak, rendering, pembersihan sampah memori, serta XMLHTTPRequest dan callback lainnya - singkatnya, oleh sejumlah hal yang terjadi di thread eksekusi utama. Ingat bagaimana saya menyebutkan "peristiwa audio" yang dapat kita jadwalkan menggunakan Web Audio API? Yah, semuanya diproses di thread terpisah - jadi meskipun thread utama terhenti sementara saat melakukan tata letak yang kompleks atau tugas panjang lainnya, audio akan tetap terjadi tepat pada waktu yang diperintahkan untuk terjadi - bahkan jika Anda dihentikan pada titik henti sementara di debugger, thread audio akan terus memutar peristiwa terjadwal.

Menggunakan JavaScript setTimeout() di Aplikasi Audio

Karena thread utama bisa dengan mudah terhenti selama beberapa milidetik dalam satu waktu, penggunaan setTimeout JavaScript untuk langsung mulai memutar peristiwa audio, karena hal terbaiknya, catatan Anda akan dipicu dalam milidetik atau lebih dari waktu yang sebenarnya, dan paling buruk, catatan akan tertunda lebih lama lagi. Yang lebih buruk dari semuanya, untuk apa yang seharusnya berirama, mereka tidak akan menembak pada interval yang tepat karena pengaturan waktunya akan sensitif terhadap hal-hal lain yang terjadi di thread JavaScript utama.

Untuk mendemonstrasikan ini, saya menulis contoh aplikasi metronom yang “buruk” - yaitu, aplikasi yang menggunakan setTimeout secara langsung untuk menjadwalkan catatan - dan juga melakukan banyak tata letak. Buka aplikasi ini, klik “play”, dan kemudian ubah ukuran jendela dengan cepat saat sedang diputar; Anda akan melihat bahwa pengaturan waktu terlihat gelisah (Anda dapat mendengar ritme yang tidak tetap konsisten). “Tapi ini rumit!” katamu? Ya, tentu saja - tetapi bukan berarti hal itu juga tidak terjadi di dunia nyata. Bahkan antarmuka pengguna yang relatif statis akan memiliki masalah pengaturan waktu di setTimeout karena relaiout - misalnya, saya melihat bahwa mengubah ukuran jendela dengan cepat akan menyebabkan pengaturan waktu pada WebkitSynth yang seharusnya terlihat patah-patah. Sekarang, bayangkan apa yang akan terjadi saat Anda mencoba men-scroll skor musik penuh beserta audio dengan lancar, dan Anda bisa dengan mudah membayangkan bagaimana pengaruhnya terhadap aplikasi musik yang kompleks di dunia nyata.

Salah satu pertanyaan paling sering diajukan yang saya dengar adalah “Mengapa saya tidak bisa mendapatkan callback dari peristiwa audio?” Meskipun mungkin ada penggunaan untuk jenis callback ini, hal ini tidak akan menyelesaikan masalah khusus yang ada - 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 untuk beberapa waktu yang tepat dan telah dijadwalkan dalam milidetik dari waktu pemrosesan yang tepat.

Jadi, apa yang bisa kita lakukan? Cara terbaik untuk menangani pengaturan waktu adalah dengan mengatur kolaborasi antara timer JavaScript (setTimeout(), setInterval(), atau requestAnimationFrame() - hal ini akan dibahas lebih lanjut nanti) dan penjadwalan hardware audio.

Memperoleh Waktu yang Tepat untuk Menatap ke Depan

Mari kembali ke demo metronom - faktanya, saya menulis versi pertama dari demo metronom sederhana ini dengan benar untuk mendemonstrasikan teknik penjadwalan kolaboratif ini. (Kode ini juga tersedia di GitHub Demo ini memutar suara bip (yang dihasilkan oleh Osilator) dengan presisi tinggi pada setiap not keenam belas, kedelapan, atau seperempat, mengubah pitch bergantung pada irama. Alat ini juga memungkinkan Anda mengubah tempo dan interval not saat diputar, atau menghentikan pemutaran kapan saja - yang merupakan fitur utama untuk sequencer ritmis dunia nyata. Akan cukup mudah menambahkan kode untuk mengubah suara yang digunakan metronom ini dengan cepat.

Caranya untuk mengizinkan kontrol sementara sambil mempertahankan pengaturan waktu yang solid adalah melalui kolaborasi: timer setTimeout yang diaktifkan sekali setiap kali, dan menyiapkan penjadwalan Audio Web di masa mendatang untuk setiap catatan. Timer setTimeout pada dasarnya hanya memeriksa untuk melihat apakah ada catatan yang perlu dijadwalkan "segera" berdasarkan tempo saat ini, lalu menjadwalkannya, seperti ini:

setTimeout() dan interaksi peristiwa audio.
setTimeout() dan interaksi peristiwa audio.

Dalam praktiknya, panggilan setTimeout() mungkin tertunda, sehingga waktu panggilan penjadwalan mungkin jitter (dan condong, bergantung pada cara Anda menggunakan setTimeout) dari waktu ke waktu - meskipun peristiwa dalam contoh ini terpicu sekitar 50 md, tetapi sering kali sedikit lebih banyak dari itu (dan terkadang jauh lebih banyak). Namun, selama setiap panggilan, kami menjadwalkan peristiwa Audio Web tidak hanya untuk semua catatan yang perlu diputar sekarang (misalnya nada pertama), tetapi juga semua catatan yang perlu diputar antara sekarang dan interval berikutnya.

Sebenarnya, kita tidak ingin hanya melihat ke depan berdasarkan interval antar-panggilan setTimeout() secara tepat - kita juga memerlukan beberapa tumpang tindih penjadwalan antara panggilan timer ini dan panggilan berikutnya, untuk mengakomodasi kasus terburuk perilaku thread utama - yaitu, kasus terburuk dari pengumpulan sampah, tata letak, rendering, atau kode lain yang terjadi di thread utama yang menunda panggilan timer berikutnya. Kami juga perlu mempertimbangkan waktu penjadwalan blok audio - yakni, berapa banyak audio yang disimpan sistem operasi dalam buffer pemrosesannya - yang bervariasi antar sistem operasi dan perangkat keras, dari yang paling rendah satu digit per milidetik hingga sekitar 50 md. Setiap panggilan setTimeout() yang ditampilkan di atas memiliki interval berwarna biru yang menunjukkan seluruh rentang waktu saat peristiwa tersebut akan mencoba menjadwalkan peristiwa; misalnya, peristiwa audio web keempat yang dijadwalkan dalam diagram di atas mungkin telah diputar “late” jika kita menunggu untuk memutarnya hingga panggilan setTimeout berikutnya terjadi, jika panggilan setTimeout beberapa milidetik kemudian. Dalam kehidupan nyata, jitter pada masa-masa ini bisa menjadi lebih ekstrem dari itu, dan tumpang-tindih ini menjadi semakin penting saat aplikasi Anda menjadi lebih kompleks.

Latensi lihat ke depan 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 berdampak pada prosesor. Seberapa banyak tampilan ke depan yang tumpang tindih dengan waktu mulai interval berikutnya akan menentukan seberapa tangguh aplikasi Anda di berbagai mesin, dan seiring dengan semakin kompleksnya (dan tata letak dan pembersihan sampah memori mungkin memerlukan waktu lebih lama). Secara umum, agar tahan terhadap mesin dan sistem operasi yang lebih lambat, sebaiknya miliki pandangan menyeluruh yang besar dan interval yang cukup pendek. Anda dapat menyesuaikannya untuk memiliki tumpang tindih yang lebih pendek dan interval yang lebih panjang, untuk memproses callback yang lebih sedikit, tetapi pada titik tertentu, Anda mungkin mulai mendengar bahwa latensi besar menyebabkan perubahan tempo, dll., untuk tidak segera berlaku; sebaliknya, jika Anda mengurangi pandangan ke depan terlalu banyak, Anda mungkin akan mulai mendengar beberapa jitter (karena panggilan penjadwalan mungkin harus "mengatasi" peristiwa yang seharusnya terjadi di masa lalu).

Diagram pengaturan waktu berikut menunjukkan fungsi kode demo metronom: diagram ini memiliki interval setTimeout 25 md, tetapi tumpang tindih yang jauh lebih tangguh: setiap panggilan akan dijadwalkan selama 100 md berikutnya. Kelemahan dari pandangan yang panjang ini adalah bahwa tempo perubahan, dll., akan membutuhkan waktu sepersepuluh detik untuk diterapkan; namun, kita jauh lebih tahan terhadap gangguan:

Penjadwalan dengan tumpang-tindih yang panjang.
penjadwalan dengan tumpang-tindih panjang

Sebenarnya, Anda dapat mengatakan dalam contoh ini bahwa kita memiliki gangguan setTimeout di tengah - kita seharusnya memiliki callback setTimeout sekitar 270 md, tetapi tertunda karena suatu alasan hingga sekitar 320 md - 50 md lebih lambat dari yang seharusnya. Namun, latensi lihat ke depan yang besar membuat pengaturan waktu berjalan tanpa masalah, dan kami tidak melewatkan irama, meskipun kami meningkatkan tempo sebelum itu untuk memainkan not keenam belas pada 240bpm (lebih dari tempo hardcore drum & bass!)

Ada kemungkinan juga bahwa setiap panggilan penjadwal pada akhirnya akan menjadwalkan beberapa catatan - mari kita lihat apa yang terjadi jika kita menggunakan interval penjadwalan yang lebih lama (lihat ke depan 250 md, berjarak 200 md), dan peningkatan tempo di tengah:

setTimeout() dengan pandangan ke depan yang panjang dan interval yang panjang.
setTimeout() dengan pandangan ke depan yang panjang dan interval panjang

Kasus ini menunjukkan bahwa setiap panggilan setTimeout() bisa jadi menjadwalkan beberapa kejadian audio - pada kenyataannya, metronom ini merupakan aplikasi 1-note-at-a-time yang sederhana, namun Anda dapat dengan mudah melihat cara kerja pendekatan ini untuk mesin drum (di mana sering kali terdapat beberapa not secara simultan) atau sequencer (yang mungkin sering memiliki interval yang tidak teratur antar not).

Dalam praktiknya, Anda ingin menyesuaikan interval penjadwalan dan melihat ke depan untuk melihat bagaimana pengaruhnya terhadap tata letak, pembersihan sampah memori, dan hal-hal lain yang terjadi di thread eksekusi JavaScript utama, dan untuk menyesuaikan tingkat perincian kontrol atas tempo, dll. Jika Anda memiliki tata letak yang sangat kompleks yang sering terjadi, misalnya, Anda mungkin ingin membuat tampilan di depan lebih besar. Poin utamanya adalah kami ingin jumlah “penjadwalan ke depan” yang kami lakukan cukup besar untuk menghindari penundaan, tetapi tidak terlalu besar sehingga dapat menyebabkan penundaan yang kentara saat menyesuaikan kontrol tempo. Bahkan kasus di atas memiliki tumpang tindih yang sangat kecil, sehingga tidak akan terlalu tahan pada mesin lambat dengan aplikasi web yang kompleks. Tempat yang baik untuk memulai mungkin 100 md waktu "{i>lookahead<i}", dengan interval yang diatur ke 25 ms. Hal ini mungkin masih memiliki masalah dalam aplikasi yang kompleks pada komputer dengan banyak latensi sistem audio, yang mengharuskan Anda menunggu lebih lama; 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 - sering kali* dalam skenario yang tepat ini, fungsi ini tidak akan melakukan apa pun (karena tidak ada "catatan" metronom yang menunggu untuk dijadwalkan, tetapi jika berhasil, fungsi akan menjadwalkan catatan tersebut menggunakan Web Audio API, dan melanjutkan ke not berikutnya.

Fungsi scheduleNote() bertanggung jawab untuk menjadwalkan “catatan” Audio Web berikutnya untuk diputar. Dalam kasus ini, saya menggunakan osilator untuk membuat bunyi bip pada frekuensi yang berbeda; Anda dapat dengan mudah membuat node AudioBufferSource dan menyetel buffer-nya ke 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 osilator tersebut dijadwalkan dan terhubung, kode ini bisa melupakannya sepenuhnya; osilator tersebut akan memulai, lalu berhenti, lalu mengumpulkan sampah secara otomatis.

Metode nextNote() bertanggung jawab untuk melanjutkan ke catatan keenam belas berikutnya - yaitu, menetapkan variabel nextNoteTime dan current16thNote ke catatan 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 nada terakhir, dan mencari tahu kapan not berikutnya dijadwalkan untuk diputar. Dengan begitu, kita bisa mengubah tempo (atau berhenti bermain) dengan sangat mudah.

Teknik penjadwalan ini digunakan oleh sejumlah aplikasi audio lain di web - misalnya, Web Audio Drum Machine, game Acid Defender yang sangat seru, dan contoh audio yang lebih mendalam seperti demo Granular Effects.

Satu Lagi Sistem Pengaturan Waktu

Sekarang, seperti yang diketahui oleh semua musisi yang baik, yang dibutuhkan setiap aplikasi audio adalah lebih banyak cowbell - em, lebih banyak timer. Perlu disebutkan bahwa cara yang tepat untuk melakukan tampilan visual adalah menggunakan sistem pengaturan waktu KETIGA!

Kenapa kita membutuhkan sistem pengaturan waktu lain? Nah, yang ini disinkronkan dengan tampilan visual - yaitu, kecepatan refresh grafis - melalui requestAnimationFrame API. Untuk menggambar kotak dalam contoh metronom kita, hal ini mungkin tidak tampak seperti masalah besar, namun seiring grafis yang semakin kompleks, semakin penting untuk menggunakan requestAnimationFrame() agar disinkronkan dengan kecepatan refresh visual - dan dari awal penggunaan ini sama mudahnya dengan menggunakan setTimeout()! Dengan grafik yang disinkronkan dan sangat kompleks (mis. tampilan yang tepat dari not balok padat saat diputar di dalam notasi musik yang paling padat saat diputar dalam permintaan AnimationFrame yang paling tepat).

Kami memantau irama dalam antrean di penjadwal:

notesInQueue.push( { note: beatNumber, time: time } );

Interaksi dengan waktu metronom saat ini dapat ditemukan dalam metode draw() yang akan dipanggil (menggunakan requestAnimationFrame) setiap kali sistem grafis siap diupdate:

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 kami sedang memeriksa jam sistem audio - karena itu benar-benar yang ingin kita sinkronkan, karena itu benar-benar akan memutar catatan - untuk melihat apakah kita harus menggambar kotak baru atau tidak. Sebenarnya, kami sama sekali tidak menggunakan stempel waktu requestAnimationFrame, karena kami menggunakan jam sistem audio untuk mengetahui posisi kami saat ini.

Tentu saja, saya bisa saja langsung melewati penggunaan callback setTimeout(), dan memasukkan penjadwal catatan saya ke dalam callback requestAnimationFrame - lalu kita akan kembali ke dua timer lagi. Hal ini juga dapat dilakukan, tetapi penting untuk dipahami bahwa requestAnimationFrame hanya merupakan pengganti setTimeout() dalam kasus ini; Anda tetap menginginkan akurasi penjadwalan pengaturan waktu Audio Web untuk catatan yang sebenarnya.

Kesimpulan

Saya harap tutorial ini bermanfaat dalam menjelaskan jam, penghitung waktu, dan cara membuat pengaturan waktu yang tepat ke dalam aplikasi audio web. Teknik yang sama ini dapat diekstrapolasi dengan mudah untuk membuat pemain urutan, mesin drum, dan banyak lagi. Sampai jumpa lagi...