Opowieść o dwóch zegarach

Precyzyjne planowanie dźwięku w internecie

Krzysiek Wiśniewski
Chris Wilson

Wstęp

Jednym z największych wyzwań w tworzeniu dobrego oprogramowania do obsługi dźwięków i muzyki z wykorzystaniem platformy internetowej jest zarządzanie czasem. Nie jak w przypadku „czasu na napisanie kodu”, ale jak w czasie zegara – jednym z najmniej dobrze rozumianych zagadnień związanych z Web Audio jest prawidłowe współdziałanie z zegarem audio. Obiekt Web AudioContext ma właściwość currentTime, która udostępnia ten zegar audio.

Szczególnie w przypadku aplikacji muzycznych w internecie – nie tylko w przypadku sekwencerów i syntezatorów, lecz także rytmu korzystania ze zdarzeń dźwiękowych, np. automatów perkusyjnych, gier i innych aplikacji. Ważne jest, aby zadbać o spójną i precyzyjną sygnaturę czasową zdarzeń audio – nie tylko w celu rozpoczęcia i zatrzymania dźwięku, ale też zaplanowania zmian w dźwięku (np. zmiany częstotliwości lub głośności). Czasami dobrze jest, gdy zdarzenia są ograniczone w czasie – na przykład w wersji demonstracyjnej karabinu maszynowego w artykule Developing Game Audio with Web Audio API (Tworzenie dźwięku gry przy użyciu interfejsu Web Audio API), ale zwykle zależy nam na tym, by nuty były odtwarzane w spójny i dokładny sposób.

Już pokazaliśmy, jak planować notatki z wykorzystaniem parametrów czasu w metodach Web Audio NoteOn i noteOff (obecnie po zmianie nazwy Start i stop) w artykule Pierwsze kroki z Web Audio oraz w artykule Tworzenie dźwięku z gry za pomocą interfejsu Web Audio API. Nie zajmujemy się jednak bardziej skomplikowanymi scenariuszami, takimi jak odtwarzanie długich sekwencji muzycznych czy rytmów. Żeby to przeanalizować, najpierw potrzebujemy informacji o zegarach.

Najlepszy czas - Zegar audio

Interfejs Web Audio API zapewnia dostęp do zegara sprzętowego podsystemu audio. Ten zegar jest wyświetlany w obiekcie AudioContext przez właściwość .currentTime jako liczba zmiennoprzecinkowa sekund od utworzenia elementu AudioContext. Dzięki temu ten zegar (nazywany dalej „zegarem audio”) może być bardzo precyzyjny i umożliwiać określenie dopasowania na poziomie konkretnej próbki dźwięku, nawet w przypadku dużej częstotliwości próbkowania. Wartość podwójna ma około 15 cyfr po przecinku, więc nawet jeśli zegar audio działa już kilka dni temu, nadal powinien mieć wystarczająco dużo bitów, aby wskazać konkretną próbkę, nawet z dużą częstotliwością próbkowania.

Zegar audio jest używany do planowania parametrów i zdarzeń audio w interfejsie Web Audio API – oczywiście w przypadku funkcji start() i stop(), ale również w metodach set*ValueAtTime() w AudioParams. Pozwala nam to z wyprzedzeniem konfigurować bardzo dokładne czasowe zdarzenia audio. W rzeczywistości ustawienie wszystkich elementów w Web Audio jako czas rozpoczęcia/zatrzymania jest kuszące, ale w praktyce niepokoi.

Spójrzmy na przykład na ten skrócony fragment kodu z naszego wprowadzenia do Web Audio, w którym są ułożone dwa takty po wzorcu hi-hat ósemkowego:

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 działa świetnie. Jeśli jednak chcecie zmienić tempo w środku tych dwóch taktów lub przestać grać, zanim dwa takty przekroczą – to szkoda. (Wiem, że programiści umieszczają węzeł wzmocnienia między wcześniej zaplanowanymi węzłami AudioBufferSourceNodes a wyjściem, aby wyciszyć własne dźwięki).

Krótko mówiąc, ponieważ potrzebujesz elastyczności, aby zmieniać tempo lub parametry, takie jak częstotliwość lub wzmocnienie (albo całkowicie zatrzymać planowanie), nie chcesz umieszczać zbyt wielu zdarzeń audio w kolejce. Mówiąc dokładniej, nie warto wybiegać zbyt daleko w czas, ponieważ być może warto całkowicie zmienić harmonogram.

Najgorsze czasy - zegar JavaScript

Mamy też nasz ulubiony i bardzo wyważony zegar JavaScript, reprezentowany przez Date.now() i setTimeout(). Zaletą zegara JavaScript jest to, że ma on kilka bardzo przydatnych metod „call-me-back-później-później” i okna.setInterval(), które pozwalają nam na wywołanie naszego kodu w określonych momentach.

Wadą zegara JavaScript jest to, że nie jest on zbyt precyzyjny. Na początek funkcja Date.now() zwraca wartość w milisekundach - liczbę całkowitą w milisekundach. Najlepsza precyzja to jedna milisekunda. W niektórych kontekstach muzycznych nie jest to zbyt złe.Jeśli nuta rozpoczęła się milisekundę wcześniej lub bardzo późno, możesz tego nawet nie zauważyć.Jednak nawet przy stosunkowo niskiej częstotliwości sprzętowej dźwięku 44,1 kHz jest to około 44,1 raza za wolno, aby używać go jako zegara harmonogramu audio. Pamiętaj, że usunięcie jakichkolwiek próbek może spowodować zakłócenia w dźwięku, więc jeśli łączysz próbki w sekwencje, muszą one występować dokładnie w sekwencji.

Najnowsze specyfikacje czasu w wysokiej rozdzielczości zapewniają nam znacznie większą precyzję wyświetlania godziny przy użyciu window.performance.now(). Jest ona nawet zaimplementowana (chociaż z prefiksem) w wielu nowoczesnych przeglądarkach. Może to być pomocne w niektórych sytuacjach, chociaż nie odnosi się do najgorszej części interfejsów API czasu JavaScriptu.

Najgorsze w przypadku interfejsów API do działania JavaScriptu jest to, że chociaż dokładność w Data.now() w milisekundach nie brzmi zbyt kiepsko, rzeczywiste wywołanie zwrotne zdarzeń licznika czasu w JavaScript (za pomocą window.setTimeout() lub window.setInterval) może zostać łatwo zniekształcone o dziesiątki lub więcej milisekund ze względu na układ, renderowanie, zbieranie pamięci, funkcję XMLHTTPRequest i inne wywołania zwrotne. Pamiętasz, jak wspomniałam o „wydarzeniach audio”, które można było zaplanować za pomocą interfejsu Web Audio API? Wszystkie te procesy są przetwarzane w osobnym wątku, więc nawet jeśli wątek główny jest tymczasowo opóźniony przy realizacji złożonego układu lub innego długiego zadania, dźwięk nadal będzie słyszalny we wskazanym czasie. W rzeczywistości nawet jeśli nastąpi zatrzymanie w debugerze w punkcie przerwania, wątek audio będzie odtwarzać zaplanowane zdarzenia.

Używanie funkcji JavaScript setTimeout() w aplikacjach audio

Wątek główny może łatwo zawiesić się na wiele milisekund naraz, dlatego lepiej jest używać funkcji JavaScript setTimeout, aby bezpośrednio rozpocząć odtwarzanie zdarzeń audio. W największym stopniu nuty będą uruchamiane w ciągu milisekundy od momentu, w którym powinny być włączone, a w najgorszym razie opóźnienia będą opóźnione jeszcze dłużej. Co najgorsze, sekwencje, które powinny być rytmami, nie będą uruchamiać się w odpowiednich odstępach czasu, ponieważ będą wrażliwe na inne zdarzenia w głównym wątku JavaScriptu.

Aby to zademonstrować, napisałam przykładową „złą” aplikację metronomu, która używa funkcji setTimeout bezpośrednio do planowania notatek, a przy tym składała się z dużych układów. Otwórz tę aplikację, kliknij „Odtwórz” i szybko zmień rozmiar okna w trakcie odtwarzania treści; zauważysz, że cykl jest wyraźnie zmienny (rytm nie utrzymuje się regularnie). „A to jest zwariowane!” – mówisz? Oczywiście. Jednak nie oznacza to, że w prawdziwym świecie tak się nie dzieje. Nawet stosunkowo statyczny interfejs użytkownika będzie mieć problemy z czasem w funkcji setTimeout z powodu przekaźników – na przykład zauważyłem, że szybka zmiana rozmiaru okna powoduje zauważalne zacinanie się czasu na doskonaleniu systemu WebkitSynth. Teraz zastanów się, co będzie się działo, gdy będziesz starać się płynnie przewijać pełną partyturę wraz z dźwiękiem, i z łatwością sobie wyobrazisz, jak wpłynie to na złożone aplikacje muzyczne w prawdziwym świecie.

Jedno z najczęstszych pytań brzmi: „Dlaczego nie mogę otrzymywać wywołań zwrotnych ze zdarzeń dźwiękowych?”. Chociaż mogą się one stosować w przypadku tego typu wywołań zwrotnych, nie rozwiązują one konkretnego problemu. Ważne jest, że zdarzenia te będą wywoływane w głównym wątku JavaScriptu, więc będą podlegać wszystkim takim samym potencjalnym opóźnieniom jak w przypadku zaplanowanych zdarzeń typu setTimeout. Oznacza to, że mogą być opóźnione w przypadku niektórych zmiennych.

Co możemy zrobić? Najlepszym sposobem na dostosowanie czasu jest skonfigurowanie współpracy między licznikami czasu JavaScript (setTimeout(), setInterval() lub requestAnimationFrame() – więcej informacji znajdziesz później) oraz planowaniem sprzętu audio.

Wykorzystanie mocnych uderzeń na głowie

Wróćmy do tej demonstracji metronomu – napisałam pierwszą wersję tej prostej demonstracji z metrem, aby zademonstrować tę technikę wspólnego planowania. (Kod jest też dostępny na GitHubie Ta wersja demonstracyjna odtwarza dźwięki (wygenerowane przez oscylator) z dużą precyzją co szesnastą, ósmą lub ćwierćnutą, zmieniając ton w zależności od bitów. Umożliwia też zmianę tempa i interwałów nut podczas gry oraz zatrzymanie odtwarzania w dowolnym momencie – jest to kluczowa funkcja w każdym sekwencerze rytmicznym w świecie rzeczywistym. Można nawet łatwo dodać kod, który zmieni dźwięki, których używa ten metronom na bieżąco.

Sposób, w jaki pozwala na kontrolę nad tymczasową pracą przy zachowaniu niezmiennego rytmu, to konstrukcja współpracy: licznik czasu setTimeout, który uruchamia się co jakiś czas i konfiguruje w przyszłości harmonogram Web Audio dla poszczególnych notatek. Licznik czasu setTimeout po prostu sprawdza, czy trzeba „wkrótce zaplanować” jakieś notatki na podstawie bieżącego tempa, a potem planuje je w ten sposób:

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

W praktyce wywołania funkcji setTimeout() mogą być opóźnione, więc czas ich planowania może z czasem ulegać zmianom (i się zmieniać w zależności od sposobu użycia setTimeout). chociaż zdarzenia w tym przykładzie są wywoływane ok. 50 ms, często są nieco większe (a czasem nawet większe). Jednak podczas każdej rozmowy zaplanujemy zdarzenia Web Audio nie tylko dla nut, które mają zostać odtworzone w danej chwili (np. pierwszej nuty), ale także wszystkich nut, które muszą zostać zagrane w tym czasie do następnego interwału.

W rzeczywistości nie chcemy po prostu sprawdzać z wyprzedzeniem dokładnie odstępów między wywołaniami funkcji setTimeout() – potrzebujemy też pewnego odstępu w harmonogramie między wywołaniem licznika czasu a następnym wywołaniem licznika, żeby uwzględnić najgorsze możliwe zachowanie wątku głównego – czyli najgorsze z powodu skasowania, układu, renderowania albo innego kodu w wątku głównym opóźniającym kolejne wywołanie licznika. Musimy też uwzględnić czas blokowy blokad dźwięku, czyli ilość dźwięku zatrzymywanego w buforze przetwarzania przez system operacyjny, który różni się w zależności od systemu operacyjnego i sprzętu – od pojedynczych cyfr w milisekundach do około 50 ms. Każde wywołanie setTimeout() wymienione powyżej ma niebieski przedział czasu, który pokazuje cały zakres prób zaplanowania zdarzeń. Na przykład czwarte zdarzenie audio w sieci, zaplanowane na powyższym schemacie, mogło zostać odtworzone „późno”, jeśli czekaliśmy na jego odtworzenie do następnego wywołania setTimeout, jeśli nastąpiło to kilka milisekund później. W rzeczywistości zakłócenia występujące w tych okresach mogą być jeszcze większe. Nakładanie się na siebie nabiera jeszcze większego znaczenia, gdy Twoja aplikacja staje się bardziej złożona.

Całkowity czas oczekiwania na sprawdzenie ma wpływ na to, jak ścisła może być kontrola tempa (i inne elementy sterujące w czasie rzeczywistym). Odstęp między wywołaniami planowania to kompromis między minimalnym czasem oczekiwania a częstotliwością wpływu kodu na procesor. Stopień pokrywania się czasu z wyprzedzeniem z czasem rozpoczęcia następnego interwału określa odporność aplikacji na różnych komputerach oraz gdy staje się ona bardziej złożona (a układ i czyszczanie pamięci może trwać dłużej). Ogólnie, aby zapewnić odporność na wolniejszych komputerów i systemów operacyjnych, najlepiej jest planować duży ruch i odpowiednio krótki odstęp czasu. Możesz dostosować krótsze odstępy czasu i nakładające się odstępy, aby przetwarzać mniej wywołań zwrotnych, ale w pewnym momencie możesz zauważyć, że duże opóźnienie powoduje zmiany tempa itp., które nie zaczynają obowiązywać natychmiast. I na odwrót, jeśli zbyt dużo na wybiegu ograniczasz czas, możesz zacząć słyszeć zakłócenia (ponieważ rozmowa planowana musi wymyślić wydarzenia, które powinny mieć miejsce w przeszłości).

Poniższy diagram pokazuje, co tak naprawdę robi kod demonstracyjny metronomu: ma on interwał setTimeout wynoszący 25 ms, ale jego zakres jest bardziej odporny na nakładanie się: każde wywołanie jest zaplanowane na następne 100 ms. Wadą takiego zaskoczenia jest to, że zmiany tempa zaczynają obowiązywać po 1/10 sekundzie, ale jesteśmy dużo silniejsi na przerwy:

Długie nakładające się harmonogramy.
harmonogram z długimi pokrywającymi się

W tym przykładzie widać, że przerwaliśmy w działaniu funkcji setTimeout . Powinno się ono mieć już czas oczekiwania na wywołanie zwrotne po około 270 ms, ale z jakiegoś powodu zostało opóźnione o około 320 ms – 50 ms później, niż powinno być. Duże opóźnienie śmiało się jednak z zachowaniem czasu i nie umknęło naszej uwadze, mimo że wcześniej zwiększyliśmy tempo do szesnastego nut w tempie 240 uderz./min (poza nawet ciężkim brzmieniem drum & bass).

Możliwe jest też, że każde wywołanie algorytmu szeregowania może spowodować zaplanowanie wielu notatek. Przyjrzyjmy się temu, co się stanie, jeśli użyjemy dłuższego interwału planowania (250 ms na wyprzedzenie i 200 ms w odstępie), a w środku nastąpi wzrost tempa:

setTimeout() z długim podglądem i długimi interwałami.
setTimeout() z długim wyprzedzeniem i długimi interwałami

Ten przypadek pokazuje, że każde wywołanie funkcji setTimeout() może spowodować zaplanowanie wielu zdarzeń dźwiękowych – w rzeczywistości jest to prosta aplikacja, w której występuje jedna notatka, ale z łatwością można sprawdzić, jak działa ta metoda w przypadku automatu perkusyjnego (gdzie jest często odtwarzanych wiele jednoczesnych nut) lub sekwencera (w których odstępy między nutami mogą często być nieregularne).

W praktyce warto dostosować interwał harmonogramu i przyszłość, aby sprawdzić, jaki wpływ ma na niego układ, czyszczenie pamięci i inne rzeczy w głównym wątku wykonywania JavaScriptu oraz dostosować szczegółowość kontroli nad tempem itp. Jeśli masz bardzo złożony układ, który często się pojawia, prawdopodobnie warto powiększyć widok z wyprzedzeniem. Głównym założeniem jest to, że zależy nam na tym, aby ilość harmonogramu była wystarczająco duża, aby uniknąć opóźnień, i nie na tyle duża, aby powodować wyraźne opóźnienie przy dostosowywaniu tempa. Nawet powyższa sytuacja ma bardzo małe pokrywanie się, więc nie będzie zbyt odporna na powolnym komputerze ze złożoną aplikacją internetową. Najlepiej zacząć od 100 ms czasu z wyprzedzeniem, z interwałami ustawionymi na 25 ms. Może to nadal powodować problemy w złożonych aplikacjach na komputerach z dużym opóźnieniem systemu audio. W takim przypadku wydłużaj czas oglądania z wyprzedzeniem. Jeśli potrzebujesz ściślejszej kontroli w związku z utratą odporności, użyj krótszego fragmentu.

Podstawowym kodem procesu planowania jest funkcja Scheduler() –

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

Ta funkcja po prostu pobiera bieżący czas działania sprzętu audio i porównuje go z czasem na następną nutę w sekwencji – w większości przypadków* w tym dokładnym scenariuszu nic nie daje (ponieważ nie ma żadnych „notatek” metronomu, które czekają na zaplanowanie, ale gdy się to uda, zaplanuje odpowiednie notatki za pomocą interfejsu Web Audio API i przechodzi do następnej nuty).

Funkcja harmonogramNote() odpowiada za planowanie kolejnej „notatki” Web Audio, która zostanie odtworzona. W tym przypadku wykorzystałem oscylatory, aby wytworzyć dźwięki w różnych częstotliwościach. Możesz też łatwo tworzyć węzły AudioBufferSource i ustawiać ich bufory na dźwięki bębnów lub dowolne 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 połączeniu tych oscylatorów kod może zupełnie o nich zapomnieć: uruchamiają się, a następnie zatrzymują, po czym są automatycznie zbierane odpady.

Metoda nextNote() odpowiada za przejście do następnej szesnastej nuty, czyli na ustawienie kolejnej notatki w przypadku zmiennych nextNoteTime i current16thNote:

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. Trzeba pamiętać, że w tym przykładzie harmonogramu nie uwzględnię „czasu sekwencji”, czyli czasu od początku metronomu. Musimy jedynie zapamiętać, kiedy graliśmy ostatnią nutę, i ustalić, kiedy ma zostać zagrana kolejna nuta. W ten sposób możemy łatwo zmienić tempo (lub przestać grać).

Z tej techniki planowania korzysta wiele innych aplikacji audio w internecie, np. Web Audio Drum Machine, bardzo zabawną grę Acid Defender i jeszcze bardziej szczegółowe przykłady dźwięku, np. prezentację Granular Effects.

Kolejny system czasu

Każdy dobry muzyk wie teraz, że każda aplikacja do odtwarzania dźwięku potrzebuje więcej krowich dzwonków – więcej minutników. Warto wspomnieć, że właściwym sposobem prezentacji wizualnej jest wykorzystanie systemu czasu TRZECI.

Dlaczego, dlaczego, dlaczego potrzebujemy innego systemu czasu? Jest ona synchronizowana z wyświetlaczem wizualnym, czyli częstotliwością odświeżania grafiki, przez interfejs requestAnimationFrame API. Jeśli w naszym przykładzie z metronomem rysuje się pudełka, może to nie być niczym wielkim, ale w miarę jak grafika staje się coraz bardziej złożona, coraz ważniejsze staje się używanie metody requestAnimationFrame() do synchronizacji z wizualną częstotliwością odświeżania. W rzeczywistości jest to od samego początku równie łatwe jak funkcja setTimeout(). W przypadku bardzo złożonej zsynchronizowanej grafiki (np. precyzyjne wyświetlanie gęstej grafiki i jej precyzyjne wyświetlanie za każdym razem, gdy odtwarzają się gęste animacje muzyczne, funkcje te są tak samo łatwe w użyciu.

Śledziliśmy bity w kolejce w algorytmie szeregowania:

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

Interakcję z bieżącym czasem metronomu można sprawdzić w metodzie pull(), która jest wywoływana (przy użyciu 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 widać, że sprawdzamy zegar systemu audio, z którym chcemy zsynchronizować dane, ponieważ to będzie grać dźwięki – aby zobaczyć, czy powinniśmy narysować nową ramkę. W rzeczywistości w ogóle nie korzystamy z sygnatur czasowych requestAnimationFrame, ponieważ do określenia, gdzie jesteśmy w czasie, używamy zegara systemowego dźwięku.

Oczywiście nie mogłam po prostu pominąć wywołania zwrotnego setTimeout() i umieścić mój algorytm szeregowania notatek w wywołaniu zwrotnym requestAnimationFrame, ale wtedy znowu występowałyby 2 stopnie. Jest to też dozwolone, ale trzeba pamiętać, że w tym przypadku metoda requestAnimationFrame jest tylko funkcją zryczałtowaną w funkcji setTimeout(). Nadal zależy nam na dokładności harmonogramu funkcji Web Audio dla rzeczywistych notatek.

Podsumowanie

Mam nadzieję, że ten samouczek okazał się pomocny przy objaśnianiu zegarów i minutników oraz wyjaśnianiu, jak wkomponować odpowiednie tempo w internetowe aplikacje audio. Te same techniki można łatwo ekstrapolować, aby zbudować m.in. odtwarzacze sekwencyjne i automaty perkusyjne. Do następnego razu...