Opowieść o dwóch zegarach

Precyzyjne planowanie dźwięku z internetu

Chris Wilson
Chris Wilson

Wprowadzenie

Jednym z największych wyzwań podczas tworzenia doskonałego oprogramowania do edycji dźwięku i muzyki na platformie internetowej jest zarządzanie czasem. Nie tak jak w przypadku „czasu na pisanie kodu”, ale tak samo jak w przypadku zegara. Jednym z najrzadziej zrozumiałego tematu Web Audio jest prawidłową pracę z zegarem audio. Obiekt Web Audio AudioContext ma właściwość currentTime, która udostępnia ten zegar audio.

Szczególnie w przypadku zastosowań muzycznych dźwięku w sieci (nie tylko sekwencerów i syntezatorach, ale też w przypadku dowolnego rytmicznego użycia zdarzeń dźwiękowych, takich jak maszyny perkusyjne, gryinne aplikacje) bardzo ważne jest spójne i precyzyjne ustalanie czasu zdarzeń dźwiękowych, nie tylko uruchamiania i zatrzymywania dźwięków, ale też planowania zmian dźwięku (np. zmiany częstotliwości lub głośności). Czasami pożądane jest występowanie zdarzeń losowo wybranych (np. w wersji demonstracyjnej z pistoletu maszynowego w artykule Tworzenie dźwięku z gier przy użyciu interfejsu Web Audio API). Zazwyczaj zależy nam jednak na tym, by nuty pojawiały się w spójnym i dokładnym momencie.

W artykule Wprowadzenie do Web Audio oraz Tworzenie dźwięku do gier za pomocą Web Audio API pokazaliśmy już, jak zaplanować odtwarzanie dźwięków za pomocą parametrów czasu metod noteOn i noteOff (teraz odpowiednio start i stop) w Web Audio. Nie zgłębiliśmy jednak bardziej skomplikowanych scenariuszy, takich jak odtwarzanie długich sekwencji muzycznych czy rytmów. Aby to wyjaśnić, najpierw musimy wyjaśnić, czym są zegary.

Największe przeboje czasu – zegar internetowy z dźwiękiem

Interfejs Web Audio API udostępnia dostęp do zegara sprzętowego podsystemu audio. Ten zegar jest widoczny w obiekcie AudioContext za pomocą właściwości .currentTime, jako liczba zmiennoprzecinkowa sekund od utworzenia obiektu AudioContext. Dzięki temu zegar (zwany dalej „zegarem audio”) ma bardzo wysoką dokładność. Został on zaprojektowany tak, aby umożliwiać określanie wyrównania na poziomie pojedynczego próbkowania dźwięku, nawet przy wysokiej częstotliwości próbkowania. Ponieważ „double” ma około 15 cyfr po przecinku, nawet jeśli zegar audio działał przez kilka dni, powinno mu pozostać wystarczająco dużo bitów, aby wskazywać konkretny próbek nawet przy wysokiej częstotliwości próbkowania.

Zegar audio służy do planowania parametrów i zdarzeń audio w interfejsie Web Audio API – oczywiście do metod start()stop(), ale też do metod set*ValueAtTime() w AudioParams. Dzięki temu możemy z wyprzedzeniem skonfigurować zdarzenia dźwiękowe o bardzo precyzyjnym czasie. W praktyce może się wydawać kuszące, aby ustawić wszystko w Web Audio jako czasy rozpoczęcia i zatrzymania, ale w praktyce może to spowodować problemy.

Spójrzmy na przykładowy fragment kodu z wprowadzenia dotyczącego dźwięku w internecie, który tworzy dwa takty hi-hat w kształcie ósmej nuty:

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

Ten kod będzie działać świetnie. Jeśli jednak chcesz zmienić tempo w środku tych 2 taktów lub zatrzymać odtwarzanie przed upływem 2 taktów, nie będziesz mieć takiej możliwości. (Widziałem, jak deweloperzy wstawiali między zaplanowanymi węzłami AudioBufferSourceNodes a wyjściem węzeł wzmocnienia, aby wyciszyć własne dźwięki).

Krótko mówiąc, ponieważ będziesz potrzebować elastyczności w zmianie tempa lub parametrów takich jak częstotliwość czy wzmocnienie (lub całkowite zatrzymanie planowania), nie chcesz umieszczać w kolejce zbyt wielu zdarzeń audio. Dokładniej mówiąc, nie chcesz zbytnio wybiegać w czasie, ponieważ możesz chcieć całkowicie zmienić planowanie.

Najgorsze czasy – zegar JavaScript

Mamy też ukochany i często krytykowany zegar JavaScript, reprezentowany przez Date.now() i setTimeout(). Zaletą zegara JavaScript jest to, że ma on kilka bardzo przydatnych metod, takich jak window.setTimeout() i window.setInterval(), które umożliwiają systemowi wywołanie kodu w określonych momentach.

Minusem zegara w JavaScript jest to, że nie jest on zbyt dokładny. Na początek funkcja Date.now() zwraca wartość w milisekundach – jako liczbę całkowitą w milisekundach – więc największa dokładność, jaką możesz uzyskać, to 1 milisekunda. W niektórych kontekstach muzycznych nie jest to zbyt duży problem – jeśli Twoja nuta zacznie się o milisekundę za wcześnie lub za późno, możesz tego nawet nie zauważyć – ale nawet przy stosunkowo niskiej częstotliwości sprzętowej dźwięku 44,1 kHz jest to około 44,1 razy za wolne, aby móc użyć go jako zegara do harmonogramowania dźwięku. Pamiętaj, że upuszczenie jakichkolwiek sampli może spowodować zakłócenia w dźwięku, jeśli więc łączymy przykłady, być może trzeba będzie je odtwarzać po kolei.

Nadchodząca specyfikacja czasu o wysokiej rozdzielczości zapewnia znacznie większą dokładność bieżącego czasu dzięki funkcji window.performance.now(); jest ona nawet zaimplementowana (choć z prefiksem) w wielu obecnych przeglądarkach. Może to być pomocne w niektórych sytuacjach, ale nie ma wpływu na najgorszą część interfejsów API czasu JavaScript.

Najgorszą rzeczą w przypadku interfejsów API do określania czasu w JavaScriptzie jest to, że chociaż dokładność do milisekund funkcji Date.now() nie wydaje się zbyt zła, rzeczywisty wywoływany przez nią kod obsługi zdarzeń zegara w JavaScriptzie (za pomocą funkcji window.setTimeout() lub window.setInterval()) może być łatwo przesunięty o kilkanaście milisekund lub więcej przez układ, renderowanie, zbieranie elementów do usunięcia i inne wywołania – krótko mówiąc, przez dowolną liczbę działań wykonywanych w głównym wątku wykonania. Pamiętasz, jak wspomniałem o „zdarzeniach audio”, które możemy zaplanować za pomocą interfejsu Web Audio API? Wszystkie te zadania są przetwarzane w ramach osobnego wątku, więc nawet jeśli wątek główny jest tymczasowo zablokowany podczas wykonywania złożonego układu lub innego długiego zadania, dźwięk będzie odtwarzany dokładnie w określonym czasie. Nawet jeśli zatrzymasz się na punkcie kontrolnym w debugerze, wątek audio będzie nadal odtwarzać zaplanowane zdarzenia.

Używanie funkcji JavaScript setTimeout() w aplikacjach audio

Główny wątek może łatwo utknąć na kilka milisekund, dlatego nie zalecamy używania metody setTimeout w JavaScript do bezpośredniego uruchamiania zdarzeń audio. W najlepszym przypadku notatki będą się uruchamiać z opóźnieniem rzędu milisekundy, a w najgorszym – jeszcze dłuższym. Co najgorsze, sekwencje, które powinny być rytmiczne, nie uruchamiają się w określonych odstępach czasu, ponieważ są zależne od tego, co dzieje się w głównym wątku JavaScriptu.

Aby to zilustrować, napisałem przykładową „złą” aplikację metronomu, która używa funkcji setTimeout bezpośrednio do planowania nut, a także wykonuje wiele operacji związanych z układem. Otwórz tę aplikację, kliknij „Odtwórz”, a następnie szybko zmień rozmiar okna podczas odtwarzania. Zauważysz, że czas trwania jest niestabilny (słyszysz, że rytm nie jest stały). „Ale to wymuszone!” – powiesz. Cóż, oczywiście – ale to nie oznacza, że tak się nie dzieje w świecie rzeczywistym. Nawet stosunkowo statyczny interfejs użytkownika będzie miał problemy z synchronizacją w setTimeout z powodu przekazywania danych – zauważyłem na przykład, że szybkie zmienianie rozmiaru okna powoduje zauważalne zacinanie w bardzo dobrej aplikacji WebkitSynth. Teraz wyobraź sobie, co się stanie, gdy spróbujesz płynnie przewinąć pełną ścieżkę dźwiękową wraz z dźwiękiem. Łatwo sobie wyobrazić, jak wpłynie to na działanie złożonych aplikacji muzycznych w prawdziwym świecie.

Jedno z najczęściej zadawanych pytań brzmi: „Dlaczego nie mogę otrzymywać wywołań zwrotnych ze zdarzeń audio?”. Chociaż te typy wywołań zwrotnych mogą być przydatne, nie stanowią one rozwiązania konkretnego problemu. Ważne jest, aby zrozumieć, że zdarzenia te są wywoływane w głównym wątku JavaScript, więc podlegałyby takim samym potencjalnym opóźnieniem jak w przypadku funkcji setTimeout, a w rzeczywistości są to zmienne i czasochłonne.

Co możemy zrobić? Najlepszym sposobem na zarządzanie czasem jest skonfigurowanie współpracy między zegarami JavaScript (setTimeout(), setInterval() lub requestAnimationFrame() – więcej informacji znajdziesz później) i harmonogramem sprzętu audio.

Uzyskiwanie solidnego rytmu dzięki spojrzeniu z wyprzedzeniem

Wróćmy do demonstracji metronomu. Pierwsza wersja tego prostego demonstracyjnego metronomu została napisana poprawnie, aby pokazać tę technikę planowania w grupie. (Kod jest też dostępny na GitHubie.) Ten demonstracyjny program odtwarza sygnały dźwiękowe (generowane przez oscylator) z wysoką precyzją w każdej 16, 8 lub 4 okresie, zmieniając wysokość dźwięku w zależności od tempa. Pozwala też w każdej chwili zmienić tempo i interwał nut podczas odtwarzania, a także zatrzymać odtwarzanie w dowolnym momencie – jest to kluczowa funkcja każdego prawdziwego sekwencera rytmicznego. Dodanie kodu, który zmienia dźwięki używane przez metronom na bieżąco, nie powinno być trudne.

Sposób, w jaki pozwala on na kontrolowanie tempa przy zachowaniu niezawodnego czasu, to współpraca: timer setTimeout, który działa co jakiś czas, i planowanie Web Audio na przyszłość dla poszczególnych nut. Licznik czasu setTimeout zasadniczo sprawdza, czy nuty trzeba będzie zaplanować „wkrótce” w zależności od aktualnego tempa, a następnie planuje je ustawić w ten sposób:

setTimeout() i interakcja z zdarzeniem audio.
setTimeout() i interakcja z zdarzeniem audio.

W praktyce wywołania setTimeout() mogą być opóźnione, więc czas wywoływania wywołań zaplanowanych może się zmieniać (i zmieniać w zależności od sposobu użycia setTimeout). Chociaż w tym przykładzie zdarzenia są wywoływane co około 50 ms, często jest to nieco dłużej (a czasami znacznie dłużej). Jednak podczas każdej rozmowy planujemy zdarzenia Web Audio nie tylko dla nut, które mają zostać odtworzone teraz (np. pierwszej nuty), ale także dla wszystkich nut, które mają zostać odtworzone od teraz do następnego interwału.

Nie chcemy tylko patrzeć w przód z dokładnym interwałem między wywołaniami setTimeout() – potrzebujemy też pewnego nakładania się harmonogramu między tym wywołaniem timera a następnym, aby uwzględnić najgorszy przypadek zachowania głównego wątku – czyli najgorszy przypadek zbierania elementów, układu, renderowania lub innego kodu działającego w głównym wątku, który opóźnia kolejne wywołanie timera. Musimy też uwzględnić czas planowania bloku dźwięku, czyli czas przechowywania dźwięku przez system operacyjny w buforze przetwarzania. Czas ten różni się w zależności od systemu operacyjnego i sprzętu – od kilku milisekund do około 50 ms. Każde wywołanie funkcji setTimeout() widoczne powyżej ma niebieski interwał wskazujący cały zakres czasu, w którym zostanie podjęta próba planowania zdarzeń. Na przykład czwarte zdarzenie audio w sieci zaplanowane na powyższym diagramie mogło zostać odtworzone „późno”, jeśli musieliśmy czekać na jego odtworzenie do następnego wywołania funkcji setTimeout, jeśli miało miejsce tylko kilka milisekund później. W rzeczywistych warunkach jitter może być jeszcze większy, a w miarę zwiększania złożoności aplikacji nakładanie się staje się jeszcze ważniejsze.

Ogólny czas oczekiwania na wstępne przetwarzanie wpływa na to, jak ścisła może być kontrola tempa (i inne kontrole w czasie rzeczywistym); interwał między wywołaniami harmonogramu to kompromis między minimalnym czasem oczekiwania a częstotliwością, z jaką kod wpływa na procesor. To, jak bardzo wyprzedzenie zachodzi na czas rozpoczęcia następnego przedziału, określa, jak odporna będzie aplikacja na różnych maszynach i jak będzie się ona zachowywać, gdy stanie się bardziej złożona (a układ i usuwanie elementów zbędnych może zająć więcej czasu). Ogólnie rzecz biorąc, aby zapewnić odporność na działanie wolniejszych maszyn i systemów operacyjnych, najlepiej jest mieć duży ogólny zasięg i dość krótki interwał. Możesz dostosować ustawienia, aby mieć krótsze nakładanie się i dłuższe odstępy, co pozwoli przetwarzać mniej wywołań zwrotnych, ale w pewnym momencie możesz zacząć słyszeć, że duża opóźnienie powoduje zmiany tempa itp., aby nie zaczęły one działać od razu; odwrotnie, jeśli zbytnio zmniejszysz czas wyprzedzania, możesz zacząć słyszeć zniekształcenia (ponieważ wywołanie harmonogramu może „nadrobić” zdarzenia, które powinny były nastąpić w przeszłości).

Poniższy diagram czasu pokazuje, co tak naprawdę robi kod demonstracyjny metronomu: ma on interwał setTimeout wynoszący 25 ms, ale znacznie bardziej odporny na zmiany: każde wywołanie będzie zaplanowane na następne 100 ms. Minusem tego długiego przewidywania jest to, że zmiany tempa itp. zaczną obowiązywać dopiero po dziesiątej części sekundy. Jednak jesteśmy znacznie bardziej odporni na przerwy:

planowanie z długimi nakładami.
planowanie z długimi nakładami czasu

W tym przykładzie widać, że w środku nastąpiła przerwa w działaniu funkcji setTimeout już w połowie – wywołanie zwrotne setTimeout powinno zostać wykonane z około 270 ms, ale z jakiegoś powodu zostało opóźnione o około 320 ms – 50 ms później niż powinno. Jednak ze względu na duże opóźnienie z wyprzedzeniem wszystko przebiegało bez problemu i nie przeoczyliśmy żadnego rytmu, chociaż tuż wcześniej zwiększyliśmy tempo do grania szesnastu nut przy 240 bpm (oprócz trudnych temp bębnów i basów).

Możliwe jest też, że każdy wywołanie harmonogramu spowoduje zaplanowanie wielu nut. Zobaczmy, co się stanie, jeśli użyjemy dłuższego przedziału czasowego (250 ms z wyprzedzeniem, co 200 ms) i zwiększymy tempo w środku:

setTimeout() z długim czasem oczekiwania i długimi interwałami.
setTimeout() z długim zasięgiem i długimi odstępami

Ten przypadek pokazuje, że każde wywołanie funkcji setTimeout() może zaplanować wiele zdarzeń dźwiękowych – tak naprawdę ten metronom to prosta aplikacja, która działa tylko na jedną nutę. Można łatwo sprawdzić, jak to działa w przypadku automatu perkusyjnego (gdzie jest często wiele jednoczesnych nut) lub sekwencera (w których często występują nieregularne odstępy między nutami).

W praktyce warto dostroić interwał planowania i przyszły, aby sprawdzić, jaki wpływ mają na niego układ, oczyszczanie pamięci i inne czynniki w głównym wątku wykonywania JavaScriptu, a także dostrajanie szczegółowości sterowania tempem itp. Jeśli masz bardzo złożony układ, który występuje często, na przykład warto zwiększyć widok z góry. Chodzi o to, abyśmy mogli „zaplanować” na tyle dużo w przód, aby uniknąć opóźnień, ale nie na tyle dużo, aby spowodować zauważalne opóźnienie podczas dostosowywania kontroli tempa. Nawet w przypadku powyżej występuje bardzo małe pokrycie, więc nie będzie ono zbyt odporne na błędy na wolnym komputerze z zaawansowaną aplikacją internetową. Warto zacząć od 100 ms „wyprzedzenia” z interwałami ustawionymi na 25 ms. Może to jednak powodować problemy w skomplikowanych aplikacjach na maszynach z dużą latencją systemu audio. W takim przypadku należy zwiększyć czas wyprzedawczy. Jeśli natomiast potrzebujesz większej kontroli przy mniejszym poziomie odporności, użyj krótszego czasu wyprzedawczego.

Główny kod procesu planowania znajduje się w funkcji scheduler().

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

Ta funkcja pobiera aktualny czas zegara sprzętowego i porównuje go z czasem następnej nuty w sekwencji. W większości przypadków* w tym konkretnym scenariuszu nie zrobi nic (ponieważ nie ma żadnych „nut” metronomu oczekujących na zaplanowanie), ale gdy się uda, zaplanowa on nutę za pomocą interfejsu Web Audio API i przejdzie do następnej nuty.

Funkcja scheduleNote() odpowiada za zaplanowanie odtwarzania kolejnej „nuty” Web Audio. W tym przypadku użyłem oscylatorów, aby wygenerować dźwięki brzęczenia o różnych częstotliwościach. Możesz też równie łatwo tworzyć węzły AudioBufferSource i ustawić ich bufory na dźwięki bębna lub inne dźwięki.

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

Po zaplanowaniu i podłączeniu oscylatorów kod może o nich całkowicie zapomnieć. Oscylatory uruchomią się, a potem zatrzymają i zostaną automatycznie usunięte.

Metoda nextNote() odpowiada za przejście do następnej szesnastki – czyli ustawienie zmiennych nextNoteTime i current16thNote na następną notę:

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

To dość proste, choć warto pamiętać, że w tym przykładzie harmonogramu nie śledzę „czasu sekwencji”, czyli czasu od momentu uruchomienia metronomu. Musimy tylko zapamiętać, kiedy zagraliśmy ostatnią nutę, i ustalić, kiedy ma zagrać następna. Dzięki temu możemy bardzo łatwo zmienić tempo (lub zatrzymać odtwarzanie).

Ta technika planowania jest używana przez wiele innych aplikacji audio w internecie, na przykład Web Audio Drum Machine, bardzo zabawną grę Acid Defender i bardziej zaawansowane przykłady dźwięku, takie jak demo Granular Effects.

Yet Another Timing System

Jak każdy dobry muzyk wie, każda aplikacja audio potrzebuje więcej dzwonków, znaczy się, więcej minutników. Warto wspomnieć, że właściwym sposobem wyświetlania wizualnego jest wykorzystanie TRZECIEGO systemu czasowego.

Czemu, raczej, dlaczego potrzebujemy innego systemu pomiaru czasu? Jest on zsynchronizowany z wizualizacją, czyli z częstotliwością odświeżania grafiki, za pomocą interfejsu requestAnimationFrame API. Dla porównania ramki do rysowania w naszym przykładzie z metronomem może to nie wydawać się zbyt dużym problemem, ale w miarę jak grafika staje się coraz bardziej złożona, coraz ważniejsze staje się korzystanie z metody requestAnimationFrame() do synchronizacji z szybkością odświeżania wizualnego. Korzystanie z funkcji setTimeout() jest tak samo łatwe w obsłudze od samego początku, jak użycie funkcji setTimeout(). W przypadku bardzo skomplikowanych zsynchronizowanych grafik (np. precyzyjne wyświetlanie gęstej grafiki i nieścisłości grafiki) w celu odtwarzania w muzyku nie zawsze precyzyjne granie zapewnia precyzyjne, precyzyjne dźwięki w animacji.

Śledzimy bity w kolejce w harmonogramie:

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

Interakcję z aktualnym czasem metronomu można znaleźć w metodzie draw(), która jest wywoływana (za pomocą requestAnimationFrame) za każdym razem, gdy system graficzny jest gotowy do aktualizacji:

var currentTime = audioContext.currentTime;

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

Ponownie sprawdzamy zegar systemu audio, ponieważ chcemy go zsynchronizować, ponieważ to on odtwarza nuty. Chcemy sprawdzić, czy powinniśmy narysować nowe pole. W zasadzie wcale nie używamy sygnatur czasowych metody requestAnimationFrame, ponieważ do określania aktualnego czasu używamy zegara systemu audio.

Oczywiście mogę całkowicie zrezygnować z używania funkcji setTimeout() i umieścić mój program planowania notatek w obsługi funkcji requestAnimationFrame – wtedy znów będziemy mieli do czynienia z dwoma zegarami. Można to też zrobić, ale w tym przypadku trzeba pamiętać, że requestAnimationFrame jest tylko zamiennikiem funkcji setTimeout(). Jednak dla rzeczywistych nut przydaje się dokładność planowania czasu Web Audio.

Podsumowanie

Mam nadzieję, że ten samouczek okazał się pomocny i wyjaśnił, jak działają zegary i liczniki czasu, i jak wbudować w aplikację do dźwięku w przeglądarce internetowej doskonałe czasy. Tych samych technik można używać do tworzenia sekwencerów, automatów perkusyjnych i innych urządzeń. Do zobaczenia…