İki saatten oluşan bir hikaye

Web sesini hassasiyetle planlama

Chris Wilson
Chris Wilson

Giriş

Web platformunu kullanarak mükemmel bir ses ve müzik yazılımı oluşturmanın en büyük zorluklarından biri, zamanı yönetmektir. "Kod yazma zamanı" değil, saat zamanı. Web Audio ile ilgili en az anlaşılan konulardan biri, sesli saatle doğru şekilde çalışmanın nasıl yapılacağıdır. Web Audio AudioContext nesnesinde, bu ses saatini gösteren bir currentTime mülkü bulunur.

Özellikle web sesinin müzikal uygulamalarında. Yalnızca ses sekanslayıcıları veya sentezleyicileri yazmakla kalmayıp davul makinesi, oyunlar ve diğer uygulamalar gibi ses etkinliklerinin ritmik kullanımı da önemlidir. Ses etkinliklerinin tutarlı ve doğru bir şekilde zamanlamasının yanı sıra seslerin duraklatılmasının yanı sıra sesteki değişikliklerin programlanması da (ör. frekans veya ses düzeyini değiştirme) önemlidir. Bazen, zaman açısından biraz rastgele olan etkinlikler istenebilir (ör. Web Audio API ile Oyun Sesi Geliştirme başlıklı makaledeki makineli tüfek demosu). Ancak genellikle, müzik notaları için tutarlı ve doğru bir zamanlama isteriz.

Web Audio'yu Kullanmaya Başlama ve Web Audio API ile Oyun Sesi Geliştirme başlıklı makalelerimizde, Web Audio noteOn ve noteOff (artık start ve stop olarak adlandırılıyor) yöntemlerinin zaman parametresini kullanarak notları nasıl planlayacağınızı göstermiştik. Ancak uzun müzik parçaları veya ritimler çalmak gibi daha karmaşık senaryoları ayrıntılı olarak incelememiştik. Bu konuyu incelemeden önce saatler hakkında biraz bilgi sahibi olmamız gerekiyor.

The Best of Times - the Web Audio Clock

Web Audio API'sı, ses alt sisteminin donanım saatine erişimi açığa çıkarır. Bu saat, AudioContext oluşturulmasından bu yana geçen saniye cinsinden bir kayan nokta sayısı olarak, AudioContext nesnesinde .currentTime özelliği aracılığıyla gösterilir. Bu saatin (bundan sonra "ses saati" olarak anılacaktır) çok yüksek doğrulukta olmasını sağlar. Yüksek örnek hızında bile tek bir ses örneği seviyesinde hizalamayı belirtebilecek şekilde tasarlanmıştır. “Çift” değerde yaklaşık 15 ondalık basamak hassasiyeti bulunduğundan, ses saati günler boyunca çalışıyor olsa bile yüksek örnek hızında bile belirli bir örneğe işaret eden pek çok bit kalmış olmalıdır.

Ses saati, Web Audio API'de parametreleri ve ses etkinliklerini planlamak için kullanılır. Elbette start() ve stop() için kullanılır ancak AudioParams'deki set*ValueAtTime() yöntemleri için de kullanılır. Bu sayede, önceden çok hassas zamanlanmış ses etkinlikleri oluşturabiliriz. Web Audio'da her şeyi başlangıç/bitiş zamanı olarak ayarlamak cazip gelebilir ancak pratikte bu yöntemle ilgili bir sorun vardır.

Örneğin, sekizinci nota hi-hat deseninde iki çubuğu ayarlayan Web Sesi Giriş bölümümüzdeki bu 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 mükemmel şekilde çalışır. Ancak bu iki çubuğun ortasında tempoyu değiştirmek veya iki çubuk dolmadan önce çalmaya son vermek isterseniz şansınız yok. (Geliştiricilerin kendi seslerini kapatabilmek için, önceden planlanmış AudioBufferSourceNode'ları ile çıktıları arasına kazanç düğümü eklemek gibi işlemler yaptıklarını gördüm.)

Kısacası, tempoyu veya sıklık veya kazanç gibi parametreleri değiştirme (ya da planlamayı tamamen durdurma) esnekliğine ihtiyaç duyacağınızdan, sıraya çok fazla sesli etkinlik yüklemek ya da daha doğrusu, planlamanızı tamamen değiştirmek isteyebileceği için çok ileriye bakmak istemezsiniz.

The Worst of Times - the JavaScript Clock

Ayrıca, Date.now() ve setTimeout() ile temsil edilen, çok sevilen ve çok eleştirilen JavaScript saatimiz de var. JavaScript saatinin iyi tarafı, sistemin kodumuzu belirli zamanlarda geri çağırmasına olanak tanıyan, çok kullanışlı birkaç "beni daha sonra ara" window.setTimeout() ve window.setInterval() yöntemine sahip olmasıdır.

JavaScript saatinin dezavantajı, çok hassas olmamasıdır. Öncelikle Date.now(), milisaniye cinsinden bir değer (tamsayı milisaniye sayısı) döndürür. Bu nedenle, elde edebileceğiniz en yüksek hassasiyet bir milisaniyedir. Bu, bazı müzikal bağlamlarda çok kötü bir durum değildir (notanız bir milisaniye erken veya geç başladıysa bunu fark etmeyebilirsiniz). Ancak 44,1 kHz gibi nispeten düşük bir ses donanım hızında bile, ses planlama saati olarak kullanmak için yaklaşık 44,1 kat daha yavaştır. Herhangi bir örnek eklemenin sesde kesintilere neden olabileceğini unutmayın. Bu nedenle, örnekleri birbirine bağlarken bunların tam olarak sıralı olması gerekebilir.

Yeni gelecek olan Yüksek Çözünürlük Süresi spesifikasyonu aslında window.performance.now() üzerinden bize çok daha iyi bir kesinlik sağlar. Hatta birçok mevcut tarayıcıda uygulanmaktadır (önek olsa da). Bu, JavaScript zamanlama API'lerinin en kötü kısmıyla pek alakalı olmasa da bazı durumlarda yardımcı olabilir.

JavaScript zamanlama API'lerinin en kötü yanı, Date.now() işlevinin milisaniye hassasiyetiyle yaşamanın çok da kötü bir şey olmamasına rağmen JavaScript'teki zamanlayıcı etkinliklerinin gerçek geri çağırmasının (window.setTimeout() veya window.setInterval aracılığıyla), düzen, oluşturma, çöp toplama, XMLHTTPRequest ve diğer geri çağırmalar gibi ana yürütme iş parçacığında gerçekleşen herhangi bir sayıda işlemden dolayı kolayca onlarca milisaniye veya daha fazla saptırılabilmesidir. Web Audio API'yi kullanarak planlayabileceğimiz "ses etkinliklerinden" bahsetmiştim. Bunların tümü ayrı bir iş parçacığında işlenir. Bu nedenle, ana iş parçacığı karmaşık bir düzen veya başka uzun bir görev yaparken geçici olarak duraklatılsa bile ses, tam olarak söylendiği zamanda oynatılmaya devam eder. Hatta hata ayıklayıcıda bir kesme noktasında durmuş olsanız bile ses iş parçacığı planlanmış etkinlikleri oynatmaya devam eder.

Ses uygulamalarında JavaScript setTimeout() kullanma

Ana iş parçacığı aynı anda birkaç milisaniye boyunca kolayca duraklayabileceğinden, sesli etkinlikleri doğrudan çalmaya başlamak için JavaScript’in setZaman aşımını kullanmak kötü bir fikirdir. Çünkü en iyi ihtimalle, notlarınız olması gerektiği zaman yaklaşık bir milisaniye içinde etkinleşir ve en kötüsü de daha da uzun süre gecikir. En kötüsü de, ritmik olması gereken sıralamalarda zamanlama ana JavaScript iş parçacığında gerçekleşen diğer işlemlere duyarlı olacağından, sıralamalar tam aralıklarla tetiklenmez.

Bunu göstermek için, notları planlamak için doğrudan setTimeout kullanan ve çok fazla düzen yapan örnek bir "kötü" metronom uygulaması yazdım. Bu uygulamayı açın, "oynat"ı tıklayın ve uygulama oynatılırken pencereyi hızlıca yeniden boyutlandırın. Zamanlamanın belirgin şekilde titrettiğini göreceksiniz (ritmin tutarlı olmadığını duyabilirsiniz). "Ama bu yapay" diyorsunuz. Elbette. Ancak bu, gerçek dünyada da yaşanmadığı anlamına gelmez. Nispeten statik kullanıcı arayüzlerinde bile, yeniden yayınlama nedeniyle setTimeout'da zamanlama sorunları yaşanır. Örneğin, pencerenin hızlı bir şekilde yeniden boyutlandırılmasının, mükemmel olan WebkitSynth'te zamanlamanın belirgin bir şekilde takılmasına neden olduğunu fark ettim. Bir müzik notasını sesinizle birlikte sorunsuzca kaydırmaya çalıştığınızda ne olacağını düşünün ve bunun gerçek dünyadaki karmaşık müzik uygulamalarını nasıl etkileyeceğini kolayca anlayabilirsiniz.

En sık duyduğum sorulardan biri "Ses etkinliklerinden neden geri çağırma alamıyorum?"dur. Bu tür geri çağırmaların kullanım alanları olsa da söz konusu sorunu çözmezler. Bu etkinliklerin ana JavaScript iş parçacığında tetikleneceğini ve bu nedenle setTimeout ile aynı olası gecikmelere tabi olacağını anlamak önemlidir. Yani, gerçekte işlenmeden önce planlandıkları tam zamandan bilinmeyen ve değişken sayıda milisaniye gecikebilirler.

Peki ne yapabiliriz? Zamanlamayı yönetmenin en iyi yolu, JavaScript zamanlayıcıları (setTimeout(), setInterval() veya requestAnimationFrame() - daha sonra bu konu hakkında daha fazla bilgi verilecektir) ile ses donanımı planlaması arasında bir işbirliği oluşturmaktır.

İleriye Bakarak Sağlam Zamanlama Elde Etme

Metronome demosuna geri dönelim. Aslında bu basit metronome demosunun ilk sürümünü, ortak planlama tekniğini göstermek için doğru şekilde yazdım. (Kodu Github'da da bulabilirsiniz. Bu demoda, her on altılık, sekizlik veya çeyrek nota için yüksek hassasiyetle bip sesleri (bir osilatörün ürettiği) çalınır ve seste, ritme bağlı olarak değişiklik yapılır. Ayrıca, oyun çalarken tempo ve nota aralığını değiştirmenize veya istediğiniz zaman oynatmayı durdurmanıza olanak tanır. Bu, gerçek dünyadaki ritmik sıralayıcıların temel özelliklerinden biridir. Bu metronomun kullandığı sesleri anında değiştirmek için kod eklemek oldukça kolaydır.

Sağlam zamanlamayı korurken geçici kontrol sağlamanın yolu bir işbirliğidir: Belirli aralıklarla tetiklenen ve gelecekte tek tek notlar için Web Audio planlamasını ayarlayan bir setTimeout zamanlayıcı. setTimeout zamanlayıcısı temel olarak mevcut tempoya göre herhangi bir notun "yakında" planlanması gerekip gerekmediğini kontrol eder ve ardından notları aşağıdaki gibi planlar:

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

Uygulamada, setTimeout() çağrıları gecikebilir. Bu nedenle, planlama çağrılarının zamanlaması zaman içinde değişebilir (ve setTimeout'u nasıl kullandığınıza bağlı olarak sapma gösterebilir). Bu örnekteki etkinlikler yaklaşık 50 ms arayla tetiklense de genellikle bundan biraz daha uzun bir süre (veya bazen çok daha uzun) arayla tetiklenir. Ancak her çağrı sırasında Web Audio etkinliklerini yalnızca şu anda çalınması gereken notlar (ör. ilk nota) için değil, şu andan sonraki aralık arasında çalınması gereken notlar için de planlarız.

Aslında, yalnızca setTimeout() çağrıları arasındaki aralığı dikkate alarak ileriye bakmak istemiyoruz. Ana iş parçacığında en kötü durumdaki davranışı (ör. ana iş parçacığında gerçekleşen en kötü durumdaki çöp toplama, düzen, oluşturma veya diğer kodlar nedeniyle bir sonraki zamanlayıcı çağrımızın gecikmesi) karşılamak için bu zamanlayıcı çağrısı ile sonraki çağrı arasında bazı planlama çakışmalarına da ihtiyacımız var. Ayrıca ses bloğu planlama süresini (yani işletim sisteminin işleme arabelleğinde ne kadar ses tuttuğunu) de hesaba katmamız gerekir. Bu süre, işletim sistemleri ve donanıma göre tek haneli milisaniyelerden yaklaşık 50 milisaniyeye kadar değişir. Yukarıda gösterilen her setTimeout() çağrısının, etkinlikleri planlamaya çalışacağı zaman aralığının tamamını gösteren mavi bir aralığı vardır. Örneğin, yukarıdaki şemada planlanan dördüncü web ses etkinliği, oynatılmasını bir sonraki setTimeout çağrısına kadar bekleseydik "geç" oynatılabilirdi. Bu setTimeout çağrısı yalnızca birkaç milisaniye geç olsaydı. Gerçek hayatta bu zamanlardaki titreme bundan daha da uç noktalarda olabilir. Uygulamanız daha karmaşık hale geldikçe bu çakışma daha da önemli hale gelir.

Genel önizleme gecikmesi, tempo kontrolünün (ve diğer gerçek zamanlı kontrollerin) ne kadar sıkı olabileceğini etkiler. Planlama çağrıları arasındaki aralık, minimum gecikme ile kodunuzun işlemciyi ne sıklıkta etkilediği arasında bir dengedir. İleriye dönük incelemenin 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 (düzen ile çöp toplama daha uzun sürebilir) belirler. Genel olarak, daha yavaş makinelere ve işletim sistemlerine karşı dayanıklı olmak için genel olarak büyük bir önizleme ve makul derecede kısa bir aralık kullanmak en iyisidir. Daha az geri çağırmayı işlemek için daha kısa çakışmalar ve daha uzun aralıklar olacak şekilde ayarlama yapabilirsiniz. Ancak bir noktada büyük bir gecikmenin tempo değişikliklerine neden olduğunu ve bunun hemen gerçekleşmeyeceğini duymaya başlayabilirsiniz. Diğer yandan, ileriyi çok fazla azaltırsanız bazı titremeler duymaya başlayabilirsiniz (planlama aramasının geçmişte olması gereken etkinlikleri "uydurması" gerekebilir).

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

Uzun çakışmalar içeren planlama.
uzun çakışmalarla planlama

Aslında bu örnekte, ortada bir setTimeout kesintisi olduğunu görebilirsiniz. Yaklaşık 270 ms'de bir setTimeout geri çağırma çağrısı almamız gerekirdi ancak bir nedenden dolayı yaklaşık 320 ms'ye kadar ertelendi. Yani olması gerekenden 50 ms daha geç gerçekleşti. Ancak büyük önizleme gecikmesi, zamanlamayı sorunsuz bir şekilde devam ettirdi ve tempoyu hemen öncesinde 240 bpm'de (sert davul ve bas tempolarının bile ötesinde) onaltılık notlar çalmak için artırmamıza rağmen tek bir vuruş atlamadık.

Her planlayıcı aramasının birden çok not zamanlaması da olabilir. Daha uzun bir planlama aralığı (250 ms ileri, 200 ms aralıklı) ve ortada bir tempo artışı kullanırsak neler olacağına bakalım:

Uzun önizleme ve uzun aralıklar içeren setTimeout().
Uzun önizleme ve uzun aralıklar içeren setTimeout()

Bu örnekte, her setTimeout() çağrısının birden fazla ses etkinliği planlayabileceği gösterilmektedir. Aslında bu metronom, tek seferde tek not çalabilen basit bir uygulamadır ancak bu yaklaşımın bir davul makinesi (sık sık eşzamanlı olarak birden fazla notun çaldığı) veya sıralayıcı (notlar arasında sık sık düzensiz aralıklar olabilir) için nasıl çalıştığını kolayca görebilirsiniz.

Uygulamada, planlama aralığınızı ve önizlemeyi, ana JavaScript yürütme iş parçacığındaki düzen, çöp toplama ve diğer işlemlerden ne kadar etkilendiğini görmek ve tempo vb. üzerindeki kontrolün ayrıntı düzeyini ayarlamak için ayarlamak istersiniz. Örneğin, sık sık gerçekleşen çok karmaşık bir düzeniniz varsa önizlemeyi daha büyük yapmak isteyebilirsiniz. Buradaki ana nokta, yaptığımız "önceden planlama" miktarının, gecikmelerin önüne geçecek kadar büyük, ancak tempo kontrolünde ayar yaparken belirgin bir gecikme oluşturacak kadar büyük olmamasıdır. Yukarıdaki örnekte bile çok küçük bir çakışma vardır. Bu nedenle, karmaşık bir web uygulamasına sahip yavaş bir makinede çok dayanıklı olmayacaktır. Başlangıç için 25 ms olarak ayarlanmış aralıklarla 100 ms "önizleme" süresi iyi bir seçim olabilir. Bu, ses sistemi gecikmesi çok fazla olan makinelerdeki karmaşık uygulamalarda yine de sorun oluşturabilir. Bu durumda, önizleme süresini artırmanız gerekir. Bazı esnekliği kaybetmek pahasına daha sıkı kontrole ihtiyacınız varsa daha kısa bir önizleme süresi kullanın.

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ı süresini alır ve bunu dizideki bir sonraki notun zamanıyla karşılaştırır. Bu kesin senaryoda çoğu zaman* hiçbir şey yapmaz (planlanmayı bekleyen metronom "notları" olmadığından, ancak başarılı olduğunda Web Audio API'sını kullanarak bu notu planlar ve bir sonraki nota ilerler.)

scheduleNote() işlevi, çalınacak bir sonraki Web Audio "notunu" planlamaktan sorumludur. Bu örnekte, farklı frekanslarda bip sesi oluşturmak için osilatörler kullandım. Siz de AudioBufferSource düğümleri oluşturabilir ve bu düğümlerin arabelleklerini davul seslerine veya istediğiniz başka 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 );

Bu osilatörler programlanıp bağlandıktan sonra bu kod, bunları tamamen unutabilir. Bunlar işlemeye başlar, ardından durur ve otomatik olarak çöp toplanır.

nextNote() yöntemi, bir sonraki onaltılık nota geçmekten (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 basit bir işlemdir. Ancak bu planlama örneğinde, "sıralı zaman"ı (yani metronomun başlatılmasından itibaren geçen süreyi) takip etmediğimi belirtmek isterim. Tek yapmamız gereken son notayı ne zaman çaldığımızı hatırlamak ve sonraki notanın ne zaman çalınacağını bulmak. Böylece, tempoyu çok kolay bir şekilde değiştirebilir (veya çalmayı durdurabiliriz).

Bu planlama tekniği, web'deki Web Audio Drum Machine ve çok eğlenceli Acid Defender oyunu gibi daha birçok ses uygulamasında ve Granular Effects demosu gibi daha derinlemesine ses örneklerinde de kullanılır.

Başka Bir Zamanlama Sistemi

İyi bir müzisyenin bildiği gibi, her ses uygulamasının daha fazla zil, yani daha fazla zamanlayıcıya ihtiyacı vardır. Görsel görüntülemeyi yapmanın doğru yolunun ÜÇÜNCÜ bir zamanlama sistemi kullanmak olduğunu belirtmek isteriz.

Neden başka bir zamanlama sistemine ihtiyacımız var? Bu, requestAnimationFrame API aracılığıyla görsel ekranla (yani grafik yenileme hızıyla) senkronize edilir. Metronom örneğimizde kutu çizmek için bu çok önemli bir şey gibi görünmeyebilir ancak grafikleriniz gittikçe karmaşık hale geldikçe görsel yenileme hızıyla senkronize etmek için requestAnimationFrame() işlevini kullanmak giderek daha da kritik hale gelir. Bu işlevin başlangıçtan itibaren kullanımı, setTimeout() işlevinin kullanımı kadar kolaydır. requestAnimationFrame(), çok karmaşık senkronize grafiklerde (ör. müzik notasyonu paketinde çalınırken yoğun müzik notalarının hassas şekilde gösterilmesi) en sorunsuz ve en hassas grafik ile ses senkronizasyonunu sağlar.

Planlayıcıda sıradaki ritimleri takip ettik:

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

Metronomumuzun mevcut zamanıyla etkileşim, grafik sistemi bir güncellemeye hazır olduğunda çağrılan (requestAnimationFrame kullanılarak) draw() yönteminde bulunabilir:

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 saat gerçekten senkronize etmek istediğimiz saattir, çünkü bu saat aslında notları çalacaktır. Yeni bir kutu çizmemiz gerekip gerekmediğini anlamak için de bu saatle senkronize etmek isteriz. Aslında, zaman içinde nerede olduğumuzu anlamak için ses sistemi saatini kullandığımızdan requestAnimationFrame zaman damgalarını hiç kullanmıyoruz.

Elbette, setTimeout() geri çağırma işlevini tamamen atlayabilir ve not planlayıcımı requestAnimationFrame geri çağırma işlevine yerleştirebilirdim. Bu durumda, tekrar iki zamanlayıcıya dönmüş olurduk. Bunu yapmak da sorun değil ancak requestAnimationFrame'in bu durumda yalnızca setTimeout() için bir yedek olduğunu anlamanız önemlidir. Gerçek notlar için Web Audio zamanlamasının planlama doğruluğunu kullanmaya devam etmeniz gerekir.

Sonuç

Bu eğitimde saatler, zamanlayıcılar ve web ses uygulamalarına mükemmel zamanlama ekleme hakkında bilgi verildiğini umuyoruz. Bu teknikler kolayca uyarlanarak dizi oynatıcıları, davul makineleri ve daha birçok özellik geliştirilebilir. Görüşmek üzere…