Studium przypadku – The Sounds of Racer

Wprowadzenie

Racer to eksperyment w Chrome dla wielu graczy i na wielu urządzeniach. Retro gra z samochodami wyścigowymi, w którą można grać na różnych ekranach. Na telefonach lub tabletach z Androidem lub iOS. Dołączyć może każdy. Brak aplikacji Bez pobierania. Tylko w internecie mobilnym

Plan8 we współpracy z naszym zespołem z 14islands stworzył dynamiczną muzykę i dźwięki na podstawie oryginalnej kompozycji Giorgio Morodera. Racer zawiera dźwięki silnika, efekty dźwiękowe związane z wyścigami, ale przede wszystkim dynamiczny mix muzyczny, który rozprowadza się na kilka urządzeń, gdy dołączają do wyścigu nowi zawodnicy. Jest to instalacja wielogłośnikowa składająca się z telefonów komórkowych.

Połączenie wielu urządzeń było dla nas od jakiegoś czasu tematem, nad którym pracowaliśmy. Przeprowadziliśmy eksperymenty z muzyką, w których dźwięk był dzielony na różne urządzenia lub przeskakiwał między nimi, więc chcieliśmy zastosować te pomysły w Racerze.

Chcieliśmy sprawdzić, czy możemy tworzyć ścieżkę dźwiękową na różnych urządzeniach, gdy do gry dołącza coraz więcej osób – zaczynając od bębnów i basu, a potem dodając gitarę i syntezatory itd. Stworzyliśmy kilka demów muzycznych i zaczęliśmy się zajmować kodowaniem. Efekt korzystania z wielu głośników był naprawdę satysfakcjonujący. W tym momencie nie wszystko było jeszcze zsynchronizowane, ale gdy usłyszeliśmy warstwy dźwięku rozłożone na różnych urządzeniach, wiedzieliśmy, że jesteśmy na dobrej drodze.

Tworzenie dźwięków

Google Creative Lab nakreśliło kierunek kreatywny dźwięku i muzyki. Efekty dźwiękowe chcieliśmy uzyskać za pomocą syntezatorów analogowych, a nie nagrywać prawdziwych dźwięków ani korzystać z bibliotek dźwiękowych. Wiedzieliśmy też, że w większości przypadków głośnik wyjściowy będzie niewielkim głośnikiem telefonu lub tabletu, więc spektrum częstotliwości dźwięku musiało być ograniczone, aby uniknąć zniekształceń. Okazało się to dość trudne. Gdy otrzymaliśmy od Giorgio pierwsze szkice muzyki, odetchnęliśmy z ulgą, ponieważ jego kompozycja idealnie pasowała do stworzonych przez nas dźwięków.

Dźwięk silnika

Największym wyzwaniem podczas programowania dźwięków było znalezienie najlepszego dźwięku silnika i stworzenie jego zachowania. Tor wyścigowy przypominał tor F1 lub Nascar, więc samochody musiały być szybkie i eksplozywne. Jednocześnie samochody były bardzo małe, więc dźwięk dużego silnika nie pasowałby do obrazu. Nie mogliśmy odtwarzać dźwięku ryczącego silnika w głośniku urządzenia mobilnego, więc musieliśmy znaleźć inne rozwiązanie.

Aby zaczerpnąć inspiracji, podłączyliśmy kolekcję syntezatorów modularnych naszego przyjaciela Jona Ekstranda i zaczęliśmy eksperymentować. Spodobało nam się to, co usłyszeliśmy. Tak brzmiało to z 2 oscylatorami, kilkoma fajnymi filtrami i LFO.

Sprzęt analogowy był już wcześniej z powodzeniem przekształcany za pomocą Web Audio API, więc mieliśmy duże nadzieje i zaczęliśmy tworzyć prosty syntezator w Web Audio. Generowany dźwięk byłby najbardziej responsywny, ale obciążyłby moc obliczeniową urządzenia. Aby zapewnić płynne działanie wizualizacji, musieliśmy zaoszczędzić jak najwięcej zasobów. Dlatego zamiast tego odtwarzamy próbki dźwięku.

syntezator modularny jako inspiracja do tworzenia dźwięków silnika;

Istnieje kilka technik, które można wykorzystać do tworzenia dźwięku silnika z sampli. Najczęstszym podejściem w przypadku gier na konsole jest użycie warstwy z wieloma dźwiękami (im więcej, tym lepiej) silnika o różnych obrotach na minutę (z obciążeniem), a następnie użycie crossfade i crosspitch. Następnie dodaj warstwę z wieloma dźwiękami silnika przy tej samej prędkości obrotowej (bez obciążenia) i zmiksuj je. Przechodzenie między tymi warstwami podczas zmiany biegów, jeśli zostanie wykonane prawidłowo, będzie brzmiało bardzo realistycznie, ale tylko wtedy, gdy masz dużą liczbę plików dźwiękowych. Przesłuch nie może być zbyt szeroki, ponieważ może brzmieć bardzo syntetycznie. Musieliśmy uniknąć długiego czasu wczytywania, więc ta opcja nie była dla nas odpowiednia. Próbowaliśmy użyć 5–6 plików dźwiękowych na każdą warstwę, ale dźwięk był rozczarowujący. Musieliśmy znaleźć sposób na zmniejszenie liczby plików.

Najskuteczniejszym rozwiązaniem okazało się:

  • 1 plik dźwiękowy z przyspieszeniem i zmianą biegów zsynchronizowaną z wizualnym przyspieszeniem samochodu, który kończy się w programowanej pętli przy najwyższej częstotliwości / liczbie obrotów na minutę. Interfejs API Web Audio bardzo dobrze radzi sobie z dokładnym odtwarzaniem pętli, więc mogliśmy to zrobić bez zniekształceń i szumów.
  • Jeden plik dźwiękowy z opóźnieniem lub z zatrzymanym silnikiem.
  • I wreszcie jeden plik dźwiękowy odtwarzający dźwięk nieczynności w pętli.

Wygląda to tak

Grafika dźwięku silnika

W przypadku pierwszego dotknięcia / przyspieszenia odtwarzamy pierwszy plik od początku, a jeśli użytkownik zwolni pedał gazu, obliczamy czas od miejsca, w którym w momencie zwolnienia znajdował się plik dźwiękowy, aby po ponownym naciśnięciu pedału gazu odtwarzanie rozpoczynało się w odpowiednim miejscu w pliku przyspieszenia po odtworzeniu drugiego pliku (z dźwiękiem).

function throttleOn(throttle) {
    //Calculate the start position depending 
    //on the current amount of throttle.
    //By multiplying throttle we get a start position 
    //between 0 and 3 seconds.
    var startPosition = throttle * 3;

    var audio = context.createBufferSource();
    audio.buffer = loadedBuffers["accelerate_and_loop"];

    //Sets the loop positions for the buffer source.
    audio.loopStart = 5;
    audio.loopEnd = 9;

    //Starts the buffer source at the current time
    //with the calculated offset.
    audio.start(context.currentTime, startPosition);
}

Spróbuj

Uruchom silnik i naciśnij przycisk „Gaz”.

<input type="button" id="playstop" value = "Start/Stop Engine" onclick='playStop()'>
<input type="button" id="throttle" value = "Throttle" onmousedown='throttleOn()' onmouseup='throttleOff()'>

Mając tylko 3 małe pliki dźwiękowe i dobrze brzmiący silnik, postanowiliśmy przejść do następnego etapu.

Synchronizacja

Wspólnie z Davidem Lindkvistem z 14islands zaczęliśmy dokładniej analizować, jak sprawić, aby urządzenia odtwarzały dźwięki w idealnej synchronizacji. Podstawowa teoria jest prosta. Urządzenie prosi serwer o czas, uwzględnia opóźnienie sieci, a następnie oblicza przesunięcie zegara lokalnego.

syncOffset = localTime - serverTime - networkLatency

Dzięki temu każde połączone urządzenie ma tę samą koncepcję czasu. Łatwe, prawda? (znowu tylko w teorii).

Obliczanie opóźnienia sieci

Możemy założyć, że opóźnienie to połowa czasu potrzebnego na wysłanie żądania i otrzymanie odpowiedzi od serwera:

networkLatency = (receivedTime - sentTime) × 0.5

Problem z tym założeniem polega na tym, że wymiana danych z serwerem nie zawsze jest symetryczna, czyli żądanie może zająć więcej czasu niż odpowiedź lub odwrotnie. Im większa jest opóźniona transmisja danych, tym większy wpływ będzie miała ta asymetria. Może ona powodować opóźnienia dźwięków i odtwarzanie ich poza synchronizacją z innymi urządzeniami.

Na szczęście nasz mózg jest tak skonstruowany, że nie zauważa niewielkich opóźnień dźwięku. Badania wykazały, że zanim nasz mózg zacznie odbierać dźwięki jako osobne dźwięki, mija 20–30 ms. Jednak po około 12–15 ms zaczniesz „odczuwać” skutki opóźnionego sygnału, nawet jeśli nie będziesz w stanie w pełni go „odczuć”. Sprawdziliśmy kilka uznanych protokołów synchronizacji czasu, prostsze alternatywy i spróbowaliśmy wdrożyć niektóre z nich w praktyce. Ostatecznie dzięki infrastrukturze Google o niskim opóźnieniu udało nam się po prostu pobrać próbkę żądań i użyć próbki o najmniejszym opóźnieniu jako odniesienia.

Zwalczanie dryfu zegara

Udało się! Mieliśmy ponad 5 urządzeń odtwarzających puls w idealnej synchronizacji, ale tylko przez chwilę. Po kilku minutach odtwarzania urządzenia odsuwały się od siebie, mimo że zaplanowaliśmy dźwięk za pomocą bardzo precyzyjnego czasu kontekstowego interfejsu Web Audio API. Opóźnienie narastało powoli, tylko o kilka milisekund naraz, i na początku było niezauważalne, ale po dłuższym odtwarzaniu powodowało całkowite rozsynchronizowanie warstw muzycznych. Cześć, zegar przesuwa się.

Rozwiązaniem było ponowne synchronizowanie co kilka sekund, obliczanie nowego przesunięcia zegara i płynne przesyłanie tych danych do harmonogramu audio. Aby zmniejszyć ryzyko zauważalnych zmian w muzyce spowodowanych opóźnieniem sieci, postanowiliśmy wygładzić zmiany, zachowując historię ostatnich przesunięć synchronizacji i obliczając średnią.

Planowanie utworu i przełączanie aranżacji

Interaktywne dźwięki oznaczają, że nie masz już kontroli nad tym, kiedy będą odtwarzane poszczególne części utworu, ponieważ zależy to od działań użytkownika. Musieliśmy mieć pewność, że będziemy mogli w odpowiednim czasie przełączać się między aranżacjami utworu, co oznacza, że nasz harmonogram musiał być w stanie obliczyć, ile czasu pozostało do końca aktualnie odtwarzanego paska przed przełączeniem na następną aranżację. Nasz algorytm wyglądał mniej więcej tak:

  • Client(1) uruchamia piosenkę.
  • Client(n) pyta pierwszego klienta, kiedy utwór został uruchomiony.
  • Client(n) oblicza punkt odniesienia, w którym utwór został uruchomiony, używając kontekstu Web Audio, uwzględniając parametr syncOffset i czas, który upłynął od utworzenia kontekstu audio.
  • playDelta = Date.now() - syncOffset - songStartTime - context.currentTime
  • Client(n) oblicza, jak długo odtwarzany jest utwór, korzystając z playDelta. Narzędzie do planowania odtwarzania utworów korzysta z tego parametru, aby wiedzieć, który takt w bieżącym układzie powinien zostać odtworzony jako następny.
  • playTime = playDelta + context.currentTime nextBar = Math.ceil((playTime % loopDuration) ÷ barDuration) % numberOfBars

Ze względu na zdrowie psychiczne ograniczyliśmy nasze aranżacje do 8 taktów i tego samego tempa (uderzeń na minutę).

Spójrz przed siebie

Podczas korzystania z funkcji setTimeout lub setInterval w JavaScript zawsze należy pamiętać o zaplanowaniu tego wcześniej. Dzieje się tak, ponieważ zegar JavaScript nie jest zbyt dokładny, a zaplanowane wywołania zwrotne mogą być łatwo przesunięte o kilkadziesiąt milisekund lub więcej przez układ, renderowanie, zbieranie elementów zbędących i wywołania XMLHTTPRequest. W naszym przypadku musieliśmy też uwzględnić czas potrzebny na to, aby wszyscy klienci otrzymali to samo zdarzenie przez sieć.

Elementy graficzne dźwięku

Połączenie dźwięków w jeden plik to świetny sposób na zmniejszenie liczby żądań HTTP zarówno w przypadku HTML Audio, jak i Web Audio API. Jest to też najlepszy sposób na odtwarzanie dźwięków za pomocą obiektu Audio, ponieważ nie trzeba wczytywać nowego obiektu Audio przed odtworzeniem. Istnieją już dobre implementacje, które posłużyły nam jako punkt wyjścia. Rozszerzyliśmy sprite, aby działał niezawodnie na urządzeniach z iOS i Androidem, a także aby obsługiwał niektóre nietypowe przypadki, gdy urządzenia przechodzą w stan uśpienia.

Na urządzeniach z Androidem elementy audio są odtwarzane nawet wtedy, gdy urządzenie jest w trybie uśpienia. W trybie uśpienia wykonywanie kodu JavaScript jest ograniczone, aby oszczędzać baterię. Nie możesz więc polegać na wywoływaniu funkcji zwrotnych requestAnimationFrame, setInterval ani setTimeout. Jest to problem, ponieważ sprite’y audio korzystają z JavaScriptu, aby sprawdzać, czy odtwarzanie powinno zostać zatrzymane. Co gorsza, w niektórych przypadkach element Audio currentTime nie aktualizuje się, mimo że dźwięk nadal jest odtwarzany.

Sprawdź wdrożenie AudioSprite, którego użyliśmy w Chrome Racer jako alternatywy dla Web Audio.

Element dźwiękowy

Gdy zaczęliśmy pracować nad Racerem, Chrome na Androida nie obsługiwał jeszcze interfejsu Web Audio API. Logika używania HTML Audio na niektórych urządzeniach i Web Audio API na innych, w połączeniu z zaawansowanym wyjściem audio, które chcieliśmy uzyskać, stanowiło ciekawe wyzwanie. Na szczęście to już przeszłość. Interfejs Web Audio API jest dostępny w Androidzie M28 w wersji beta.

  • opóźnienia lub problemy z czasem; Element Audio nie zawsze odtwarza się dokładnie wtedy, gdy go wywołasz. Ponieważ JavaScript jest jednowątkowy, przeglądarka może być zajęta, co powoduje opóźnienia odtwarzania do 2 sekund.
  • Opóźnienia odtwarzania oznaczają, że płynne odtwarzanie w pętli nie zawsze jest możliwe. Na komputerze możesz użyć podwójnego buforowania, aby uzyskać pętlę bez przerw, ale na urządzeniach mobilnych nie jest to możliwe, ponieważ:
    • Większość urządzeń mobilnych nie odtwarza więcej niż 1 elementu audio naraz.
    • Stała głośność. Ani Android, ani iOS nie umożliwiają zmiany głośności obiektu Audio.
  • Bez wstępnego wczytywania. Na urządzeniach mobilnych element Audio nie zacznie wczytywać źródła, dopóki odtwarzanie nie zostanie zainicjowane w obiekcie touchStart.
  • Szukam problemów. Pobieranie wartości duration lub ustawianie wartości currentTime nie powiedzie się, chyba że serwer obsługuje zakres bajtów HTTP. Jeśli tworzysz sprite audio, jak my, zwróć uwagę na ten element.
  • Podstawowe uwierzytelnianie w przypadku MP3 nie działa. Na niektórych urządzeniach nie można wczytać plików MP3 chronionych przez uwierzytelnianie podstawowe, niezależnie od używanej przeglądarki.

Podsumowanie

Od czasu, gdy wyciszenie było najlepszą opcją w przypadku dźwięku w internecie, poczyniliśmy ogromne postępy, ale to dopiero początek. Dźwięk w internecie wkrótce będzie robił prawdziwą furorę. To tylko wierzchołek góry lodowej, jeśli chodzi o możliwości synchronizacji wielu urządzeń. W telefonach i tabletach nie mieliśmy wystarczającej mocy obliczeniowej, aby zająć się przetwarzaniem sygnału i efektami (np. pogłosem), ale wraz ze wzrostem wydajności urządzeń gry internetowe będą mogły korzystać z tych funkcji. To ekscytujący czas, w którym można dalej rozwijać możliwości dźwięku.