İki saatten oluşan bir hikaye

Web sesini hassas bir şekilde planlama

Can Yılmaz
Chris Wilson

Giriş

Web platformunu kullanarak başarılı ses ve müzik yazılımları oluşturmanın en büyük zorluklarından biri zaman yönetimidir. "Kod yazma zamanı" gibi değil, saatteki gibi. Web Audio hakkında en az anlaşılan konulardan biri, sesli saatin nasıl düzgün bir şekilde çalışacağıdır. Web Audio AudioContext nesnesi, bu ses saatini gösteren bir currentTime özelliğine sahiptir.

Özellikle web sesi için müzik uygulamaları - yalnızca sıralayıcılar ve sentezleyiciler yazmak değil, aynı zamanda davul makineleri, oyunlar ve diğer uygulamalar gibi ses etkinliklerinin her türlü ritmik kullanımı için; yalnızca sesleri başlatmak ve durdurmak değil, aynı zamanda ses etkinliklerini (ör. değişen frekansı veya ses düzeyini) ayarlamak değil, ses etkinliklerinin tutarlı ve hassas bir şekilde zamanlamasını sağlamak çok önemlidir. Bazen örneğin Web Audio API'sı ile Oyun Sesi Geliştirme'deki makineli silah demosundaki etkinliklerin biraz rastgele olması tercih edilir, ancak genellikle müzik notalarının zamanlamasının tutarlı ve doğru olmasını isteriz.

Web Audio'yu Kullanmaya Başlama ve Web Audio API'si ile Oyun Sesi Geliştirme bölümlerinde Web Audio noteOn ve noteOff (artık "başla ve durdur" olarak adlandırılmıştır) yöntemlerinin zaman parametresini kullanarak nasıl not planlayabileceğinizi göstermiştik. Ancak, uzun müzik dizilerini veya ritimleri çalmak gibi daha karmaşık senaryoları derinlemesine incelemedik. Bu konuyu ayrıntılı olarak ele alabilmemiz için öncelikle saatlerle ilgili biraz bilgi sahibi olmamız gerekiyor.

En İyi Zamanlar - Web Sesli Saat

Web Audio API, ses alt sisteminin donanım saatine erişim sağlar. Bu saat, AudioContext nesnesinde .currentTime mülkü aracılığıyla, AudioContext nesnesinin oluşturulmasından itibaren geçen saniyelerin kayan nokta sayısı olarak gösterilmektedir. Bu özellik, bu saatin (bundan sonra "ses saati" olarak anılacaktır) oldukça yüksek hassasiyete sahip olmasını sağlar. Yüksek bir örnek hızı bile olsa tek bir ses örneği düzeyinde hizalamayı belirleyebilecek şekilde tasarlanmıştır. Bir "çift" sayı olan bir dizede yaklaşık 15 ondalık basamak hassasiyeti olduğundan, ses saati günlerdir çalışsa bile yüksek bir örnek hızında bile belirli bir örneği işaret edecek çok sayıda bit kalmış olmalıdır.

Sesli saat, Web Audio API'sındaki parametreleri ve ses etkinliklerini planlamak için kullanılır. start() ve stop() işlemleri için, aynı zamanda AudioParams'daki set*ValueAtTime() yöntemleri için de kullanılır. Bu, çok hassas zamanlanmış sesli etkinlikleri önceden oluşturmamıza olanak tanır. Aslında, Web Sesi'ndeki her şeyi başlangıç/durdurma zamanları olarak ayarlamak cazip gelebilir ancak pratikte böyle bir sorun söz konusudur.

Örneğin, Web Sesi Girişi'nde yer alan ve sekizlik nota şeklindeki hi-hat desenli iki çubuğun yer aldığı şu azaltılmış kod snippet'ine bakın:

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);
  }

Bu kod çok işe yarayacak. Ancak bu iki çubuğun ortasındaki tempoyu değiştirmek veya iki çubuk yükselmeden çalmayı bırakmak istiyorsanız bunu başaramazsınız. (Geliştiricilerin kendi seslerini kapatabilmeleri için önceden planlanmış AudioBufferSourceNodes'ları ile çıkışlarının arasına kazanç düğümü eklemek gibi şeyler yaptığını görmüştüm.)

Kısacası, tempoyu veya sıklık ya da kazanç gibi parametreleri veya parametreleri (sıklık ya da kazanç) değiştirme esnekliğine sahip olmanız (ya da planlamayı tamamen durdurmanız) gerekeceğinden, kuyruğa çok fazla sesli etkinlik göndermek ya da daha doğrusu, bu planlamayı tamamen değiştirmek isteyebileceğinizden çok daha ileriye bakmak istemezsiniz.

En Kötü Zamanlar - JavaScript Saat

Ayrıca, Date.now() ve setTimeout() ile temsil edilen çok sevilen ve çok kötü uyumlu JavaScript saatimiz de vardır. JavaScript saatinin iyi tarafı, sistemin kodumuzu belirli zamanlarda geri çağırmasını sağlayan bazı kullanışlı "call-me-back-later window.setTimeout()" ve window.setInterval() yöntemlerine sahip olmasıdır.

JavaScript saatinin kötü tarafı, çok hassas olmamasıdır. Başlangıç olarak Date.now(), milisaniye cinsinden bir değer (milisaniye cinsinden tam sayı) döndürür. Dolayısıyla umduğunuz en iyi kesinlik değeri bir milisaniyedir. Bazı müzik bağlamlarında bu çok kötü bir durum değildir.Nota milisaniyeniz erken ya da geç başlasa bile fark etmeyebilirsiniz.Ancak 44, 1 kHz gibi nispeten düşük bir ses donanım hızında bile ses zamanlama için 44, 1 kat fazla yavaş kullanılmaktadır. Herhangi bir örnek bırakmanın seste aksaklıklara neden olabileceğini unutmayın. Bu nedenle, örnekleri birbirlerine bağlıyorsak bunların tam olarak sıralı olmaları gerekebilir.

Gelişmekte olan Yüksek Çözünürlük Süresi spesifikasyonu aslında window.performance.now() aracılığıyla geçerli zamanı çok daha kesin bir şekilde belirlememizi sağlamaktadır. Bu özellik, mevcut birçok tarayıcıda (önekli olsa da) bile uygulanmaktadır. Bu, JavaScript zamanlama API'larının en kötü kısmıyla gerçekten alakalı olmasa da, bazı durumlarda işe yarayabilir.

JavaScript zamanlama API'lerinin en kötü yanı, Date.now()’un milisaniye hassasiyeti kulağa çok kötü gelmese de, JavaScript'teki zamanlayıcı etkinliklerinin (window.setTimeout() veya window.setInterval aracılığıyla) asıl geri çağırmasının, düzen, oluşturma, atık toplama, XMLHTTPRequest ve kısa bir sayıda yürütülmesiyle gerçekleşen diğer geri çağırma işlemlerine göre onlarca milisaniye veya daha uzun bir süre boyunca kolayca çarpıtılabilmesidir. Web Audio API'sını kullanarak planlayabileceğimiz "sesli etkinliklerden" nasıl bahsettiğimi hatırlıyor musunuz? Bunların tümü ayrı bir iş parçacığında işlenir. Böylece, ana iş parçacığı karmaşık bir düzen veya başka bir uzun görev yaparken geçici olarak durmuş olsa bile ses, tam olarak onlara söylendiği anda çalmaya devam eder. Hatta hata ayıklayıcıdaki bir kesme noktasında durmuş olsanız bile ses ileti dizisi, planlanmış etkinlikleri oynatmaya devam eder.

Ses Uygulamalarında JavaScript setTimeout() kullanma

Ana iş parçacığı aynı anda birkaç milisaniye boyunca kolayca duraklayabileceğinden, doğrudan ses etkinliklerini oynatmaya başlamak için JavaScript'in setTimeout'unu kullanmak kötü bir fikirdir, çünkü en iyi durumda notlarınız gerçekte olması gerektiği zamanda bir milisaniye civarında tetiklenir ve en kötüsü, bunlar daha da uzun süre gecikir. Hepsinden kötüsü, zamanlama ana JavaScript iş parçacığında meydana gelen diğer şeylere duyarlı olacağı için ritmik dizilerin ne olması gerektiği konusunda belirli aralıklarla tetiklenmezler.

Bunu göstermek için örnek bir “kötü” metronom uygulaması yazdım (notları planlamak için doğrudan setTimeout kullanan ve aynı zamanda da pek çok düzen yapan bir uygulama). Bu uygulamayı açın, "oynat"ı tıklayın ve müzik çalarken pencereyi hızlıca yeniden boyutlandırın. Zamanlamanın belirgin bir şekilde titrediğini fark edersiniz (ritmin tutarlı olmadığını duyabilirsiniz). “Ama bu gerçek bir sahte!” diyor musunuz? Elbette, bu durum gerçek dünyada da yaşanmadığı anlamına gelmez. Nispeten statik bir kullanıcı arayüzünde bile geçişler nedeniyle setTimeout'ta zamanlama sorunları yaşanacaktır. Örneğin, pencereyi hızlı bir şekilde yeniden boyutlandırmanın, mükemmel olan WebkitSynth'te zamanlamanın gözle görülür şekilde takılmaya neden olacağını fark ettim. Şimdi ses kaydınızla birlikte müzik notalarını düzgün bir şekilde kaydırmaya çalıştığınızda ne olacağını hayal edin. Bu işlemin gerçek dünyadaki karmaşık müzik uygulamalarını nasıl etkileyeceğini kolayca düşünebilirsiniz.

En sık sorulan sorulardan biri "Sesli etkinliklerden neden geri arama alamıyorum?" sorusudur. Bu tür geri çağırma işlevleri için kullanım alanları olabilir, ancak söz konusu etkinlikler belirli bir sorunu çözmeyecektir. Bu etkinliklerin ana JavaScript iş parçacığında tetikleneceğinin anlaşılması önemlidir; bu nedenle, setTimeout'ta görülen olası gecikmelerin hepsinde zaman aşımı yaşanmış olacaktır. Örneğin, hassas zaman aşımından önce işlenmeleri için sayı milisaniye ve olarak ertelenebilirdi.

Bu durumda ne yapabiliriz? Zamanlamayı yönetmenin en iyi yolu, JavaScript kronometreleri (setTimeout(), setInterval() veya requestAnimationFrame() - daha sonra bununla ilgili daha ayrıntılı bilgi) ile ses donanımı planlaması arasında bir ortak çalışma kurmaktır.

Önceden Bakarak Çok Katı Zamanlama Elde Etme

Şu metronom demosuna geri dönelim; ben bu basit metronom demosunun ilk sürümünü, iş birliğine dayalı planlama tekniğini göstermek için doğru bir şekilde yazdım. (Bu kod GitHub'da da mevcuttur Bu demo, bip seslerini (bir Osilatör tarafından oluşturulur) her on altıda, sekizde ve çeyrek notada yüksek hassasiyetle çalar ve vuruşa göre ses tonunu değiştirir. Ayrıca, şarkı çalarken tempoyu ve nota aralığını değiştirmenize veya istediğiniz zaman oynatmayı durdurmanıza olanak tanır. Bu özellik, gerçek dünyadaki ritmik düzenleyicilerin temel özelliklerinden biridir. Bu metronomun çalışırken kullandığı sesleri de değiştirmek için kod eklemek oldukça kolay olurdu.

Harika bir zamanlamayı korurken sıcaklık kontrolüne izin vermeyi başaran bir ortak çalışma yöntemi var: Sık sık etkinleşen bir setTimeout zamanlayıcısı ve bağımsız notlar için gelecekte Web Audio programlaması ayarlar. setTimeout zamanlayıcısı temel olarak notların mevcut tempoya göre "yakında" ayarlanması gerekip gerekmediğini kontrol eder ve ardından aşağıdaki gibi planlar:

setTimeout() ve ses etkinliği etkileşimi.
setTimeout() ve ses etkinliği etkileşimi.

Pratikte, setTimeout() çağrıları gecikebilir. Bu nedenle, planlama çağrılarının zamanlaması zaman içinde titreyebilir (ve setTimeout'u kullanma şeklinize bağlı olarak). Bu örnekteki etkinlikler yaklaşık 50 ms. aralıklarla etkinleşse de çoğu zaman bundan biraz daha fazladır (ve bazen çok daha fazla). Bununla birlikte, her arama sırasında Web Audio etkinliklerini yalnızca o anda çalınması gereken notlar (örneğin, ilk not) için değil, o andan itibaren bir sonraki aralığa kadar çalınması gereken tüm notalar için de planlarız.

Aslında, yalnızca setTimeout() çağrıları arasındaki aralıkla tam olarak ileriye bakmak istemiyoruz. En kötü durumda ana iş parçacığı davranışına (yani bir sonraki zamanlayıcı çağrımızı geciktiren ana iş parçacığında oluşan en kötü durumda) uyum sağlamak için bu kronometre çağrısı ile bir sonraki zaman arasında bir miktar planlama çakışmasına da ihtiyacımız var. Ayrıca, ses bloğu planlama süresini de (işletim sisteminin işleme arabelleğinde ne kadar ses sakladığını) da hesaba katmamız gerekir. Bu süre, işletim sistemlerine ve donanımlara göre (milisaniye cinsinden küçük tek haneli değerlerden yaklaşık 50 ms civarında) değişir. Yukarıda gösterilen her setTimeout() çağrısı, etkinlikleri planlamaya çalışacağı tüm zaman aralığını gösteren mavi bir aralığa sahiptir. Örneğin, yukarıdaki şemada planlanan dördüncü web ses etkinliği, bir sonraki setTimeout çağrısının gerçekleşmesine kadar (bu setTimeout çağrısı yalnızca birkaç milisaniye sonraysa) çalınmasını beklediysek, "late" olarak oynatılmış olabilir. Gerçek hayatta, bu zamanlardaki ses dalgalanması bundan daha uçucu olabilir ve uygulamanız daha karmaşık hale geldikçe bu çakışma daha da önemli hale gelir.

Genel ileriye dönük gecikme, tempo kontrolünün (ve diğer gerçek zamanlı kontrollerin) ne kadar sıkı olabileceğini etkiler. Aramaları planlama arasındaki aralık, minimum gecikme ile kodunuzun işlemciyi etkileme sıklığı arasında bir denge oluşturur. Ön görünümünün bir sonraki aralığın başlangıç zamanıyla ne kadar çakıştığı, uygulamanızın farklı makinelerde ne kadar dayanıklı olacağını ve daha karmaşık hale geldikçe (ve düzen ile çöp toplama işlemi daha uzun sürebilir) belirlenir. Genel olarak, daha yavaş makinelere ve işletim sistemlerine karşı dirençli olmak için en iyisi geniş bir genel öngörüye ve makul ölçüde kısa bir aralığa sahip olmaktır. Geri arama sayısını daha az işlemek için daha kısa çakışmalar ve daha uzun aralıklar ayarlayabilirsiniz. Ancak bir noktada, uzun bir gecikmenin zaman değişikliklerine neden olduğunu ve bu değişikliklerin hemen geçerli olmadığını duymaya başlayabilirsiniz. Tam tersi olarak, ön görüntüyü çok fazla küçültürseniz bazı titremeler duymaya başlayabilirsiniz (çünkü bir programlama çağrısının geçmişte gerçekleşmiş olması gereken etkinlikleri "uyarlamasına" yol açabilir).

Aşağıdaki zamanlama şemasında metronom demo kodunun gerçekte ne yaptığı gösterilmektedir: 25 ms'lik bir setTimeout aralığına sahiptir ancak çok daha esnek bir çakışma vardır: Her çağrı sonraki 100 ms için planlanır. Bu uzun planlamanın olumsuz tarafı, tempo değişikliklerinin vb. devreye girmesinin bir saniyenin onda biri kadar sürmesidir. Ancak, kesintilere karşı çok daha dirençliyiz:

Uzun çakışmaların olduğu planlamalar.
uzun çakışmaların olduğu planlama

Bu örnekte, ortada bir setTimeout kesintisi yaşadığımızı söyleyebilirsiniz. Yaklaşık 270 ms.de bir setTimeout geri çağırması yaşamış olmalıydık, ancak bir nedenden dolayı bu süre olması gerekenden yaklaşık 320 ms - 50 ms sonrasına ertelenmişti! Ancak ilerideki uzun gecikme nedeniyle zamanlama hiç sorun yaşamadan devam etti ve tempoyu bundan hemen önce yükseltip 240 nabız/dk'da on altıncı notalara kadar yükseltmemize rağmen ritmi kaçırmadık.

Her planlayıcı aramasının sonunda birden çok not olması da mümkündür. Daha uzun bir planlama aralığı (250 ms'den ileri, 200 ms aralıklarla) kullanırsak ve ortada bir tempo artışı kullanırsak ne olacağına bir bakalım:

uzun aralı ve uzun zaman aralıklarıyla setTimeout() kullanımı.
Uzun ileri yönlü ve uzun aralıklarla setTimeout()

Bu durum, her setTimeout() çağrısının birden çok ses etkinliği programlamasıyla sonuçlanabileceğini gösterir. Aslında, bu metronom basit bir tek notluk uygulamadır, ancak bu yaklaşımın bir davul makinesi (sıklıkla aynı anda birden fazla notun bulunduğu) veya bir sıralayıcı (notlar arasında sıklıkla düzensiz aralıklar olabilecek) için nasıl çalıştığını kolayca görebilirsiniz.

Pratikte, ana JavaScript yürütme iş parçacığında düzen, çöp toplama ve diğer şeylerden ne kadar etkilendiğini görmek ve hız üzerindeki denetimin ayrıntı düzeyini ayarlamak gibi, sık sık gerçekleşen çok karmaşık bir düzeniniz varsa büyük olasılıkla bu görünümü daha büyütmek isteyebilirsiniz. Buradaki temel nokta, yaptığımız "ileriye yönelik planlama" miktarının gecikmeleri önleyecek kadar büyük ama tempo kontrolünde değişiklik yaparken fark edilebilir bir gecikme oluşturacak kadar büyük olmamasıdır. Yukarıdaki örnekte bile çok küçük bir örtüşme vardır. Bu nedenle, karmaşık bir web uygulamasına sahip yavaş bir makinede çok dirençli olmayacaktır. 100 ms'lik "ileride" süresi, başlamak için iyi bir seçenektir ve aralıklar 25 ms'ye ayarlanır. Bu durum, ses sistemi gecikmesinin yüksek olduğu makinelerdeki karmaşık uygulamalarda yine de sorunlar yaratabilir. Bu durumda, ileri arama süresini artırmanız veya daha fazla esneklik kaybederek daha sıkı kontrole ihtiyacınız varsa daha kısa bir ön izleme kullanmanız gerekir.

Planlama sürecinin temel kodu scheduler() işlevindedir.

while (nextNoteTime < audioContext.currentTime + scheduleAheadTime ) {
  scheduleNote( current16thNote, nextNoteTime );
  nextNote();
}

Bu işlev yalnızca geçerli ses donanımı zamanını alır ve bunu, dizideki bir sonraki notun zamanıyla karşılaştırır. Bu tams senaryoda, çoğu zaman* hiçbir şey yapmaz (çünkü planlanması gereken herhangi bir metronom "notu" yoktur, ancak işlem başarılı olduğunda, not Web Audio API'sını kullanarak bu notu planlayıp sonraki nota geçer).

scheduleNote() işlevi, çalınacak bir sonraki Web Sesi “notunu” planlamaktan sorumludur. Bu örnekte, farklı frekanslarda bipleme sesleri çıkarmak için osilatörleri kullandım; siz de AudioBufferSource düğümlerini kolayca oluşturabilir ve tamponlarını davul seslerine ya da istediğiniz diğer seslere ayarlayabilirsiniz.

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 );

Söz konusu osilatörler programlanıp bağlandıktan sonra, bu kod onları tamamen unutabilir. Başlar, sonra durur ve çöpleri otomatik olarak toplar.

nextNote() yöntemi bir sonraki on altıncı nota ilerlemekten sorumludur. Yani, nextNoteTime ve current16thNote değişkenlerini bir sonraki nota ayarlamaktan sorumludur:

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;
  }
}

Bu oldukça basittir. Yine de bu zaman çizelgesi örneğinde, "dizi zamanını", yani metronomun başlatılmasından bu yana olan süreyi takip etmediğimi anlamak önemlidir. Tek yapmamız gereken son notayı ne zaman çaldığımızı hatırlamak ve bir sonraki notanın ne zaman çalınmak üzere planlandığını belirlemek. Bu şekilde tempoyu kolayca değiştirebiliriz (veya çalmayı durdurabiliriz).

Bu planlama tekniği, web'deki bir dizi başka ses uygulaması tarafından kullanılmaktadır. Örneğin, Web Audio Davul Makinesi, çok eğlenceli Acid Defender oyunu ve Granular Effects demosu gibi daha da ayrıntılı ses örnekleri kullanabilirsiniz.

Başka Bir Zamanlama Sistemi

Tüm iyi müzisyenlerin bildiği gibi, her ses uygulamasının ihtiyacı olan şey daha fazla zamanlayıcıdır. Görsel görüntüleme yapmanın doğru yolunun, ÜÇÜNCÜ bir zamanlama sisteminden yararlanmak olduğunu belirtmekte fayda var!

Neden başka bir zamanlama sistemine ihtiyacımız var? Bu örnek, requestAnimationFrame API'si aracılığıyla görsel görüntüyle, yani grafik yenileme hızıyla senkronize edilir. Metronom örneğimizdeki çizim kutuları için bu çok büyük bir sorun gibi görünmeyebilir, ancak grafikleriniz gittikçe daha karmaşık bir hale geldikçe, görsel yenileme hızıyla senkronize etmek için requestAnimationFrame() işlevinin kullanılması giderek daha önemli hale gelmiştir. Bu kullanımın en başından itibaren setTimeout() kullanımı kadar kolaydır! Çok karmaşık senkronize grafiklerle (örneğin, müzik notalarının en net şekilde görüntülenmesi, müzik notalarının en net şekilde görüntülenmesini sağlar.)

Planlayıcıdaki sırada bekleyen vuruşları takip ettik:

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

Metronomumuzun geçerli zamanıyla olan etkileşim,draw() yönteminde bulunabilir. Bu yöntem, grafik sistemi güncelleme için hazır olduğunda (requestAnimationFrame kullanarak) çağrılır:

var currentTime = audioContext.currentTime;

while (notesInQueue.length && notesInQueue[0].time < currentTime) {
  currentNote = notesInQueue[0].note;
  notesInQueue.splice(0,1);   // remove note from queue
}

Ses sisteminin saatini kontrol ettiğimizi fark edeceksiniz; çünkü bu, gerçekten notaları çalacağından, yeni bir kutu çizmemiz gerekip gerekmediğine karar vermek için senkronize etmek istediğimiz saat bu. Aslında zamanda nerede olduğumuzu anlamak için ses sistemi saatini kullandığımızdan requestAnimationFrame zaman damgalarını hiç kullanmıyoruz.

Elbette, setTimeout() çağrısını kullanarak tamamen atlayabilir ve not planlayıcımı requestAnimationFrame geri çağırmasına yerleştirebilirdim. Sonra da tekrar iki kronometreye geri dönerdik. Bunu da yapabilirsiniz, ancak bu durumda requestAnimationFrame yalnızca setTimeout() için bir yedek olduğunu anlamak önemlidir; asıl notalar için Web Audio zamanlamasının zamanlama doğruluğunu istersiniz.

Sonuç

Bu eğiticinin saatleri, zamanlayıcıları ve web ses uygulamalarına nasıl mükemmel zamanlama sağlayacağını açıklama konusunda faydalı olduğunu umuyorum. Bu teknikler sekans çalarlar, davul makineleri ve daha pek çok şey oluşturmak için kolayca uygulanabilir. Görüşmek üzere...