Studium przypadku – The Sounds of Racer

Wstęp

Racer to Eksperyment Chrome z udziałem wielu graczy i urządzeń. To tytuł w stylu retro, w którym można grać na różnych urządzeniach. Na telefonach i tabletach z Androidem lub iOS. Każdy może dołączyć. Brak aplikacji. Bez pobierania. Tylko internet mobilny

Zespół Plan8 wspólnie z przyjaciółmi z 14islands stworzył dynamiczną muzykę i dźwięk na podstawie kompozycji Giorgia Morodera. W porównaniu z grami wyścigowymi pojawiają się dynamiczne dźwięki silnika i wyścigowe efekty dźwiękowe, a przede wszystkim dynamiczną składankę muzyczną, która rozkłada się na kilku urządzeniach. To instalacja wielogłośnikowa składająca się ze smartfonów.

Zajmowaliśmy się łączeniem wielu urządzeń ze sobą od jakiegoś czasu. Mieliśmy eksperymenty muzyczne, w których dźwięk dzielił się na różne urządzenia lub przeskakiwał między urządzeniami, więc chcieliśmy wykorzystać te pomysły w Racer.

Chcieliśmy dokładnie sprawdzić, czy uda się nam opublikować utwór na różnych urządzeniach w miarę jak coraz więcej osób dołącza do gry – zaczynając od perkusji i basu, a następnie dodając gitarę i syntezatory. Zrobiliśmy kilka pokazów muzycznych i zainteresowaliśmy się kodowaniem. Efekt korzystania z kilku głośników był naprawdę satysfakcjonujący. Nie mieliśmy teraz takiej pełnej synchronizacji, ale gdy usłyszeliśmy kolejne warstwy dźwięku z różnych urządzeń, wiedzieliśmy, że czeka nas coś dobrego.

Tworzę dźwięki

Zespół Google Creative Lab wyznaczył twórczy kierunek rozwoju dźwięku i muzyki. Chcieliśmy wykorzystać do tworzenia efektów dźwiękowych syntezatory analogowe, zamiast nagrywać prawdziwe dźwięki czy korzystać z bibliotek dźwiękowych. Wiedzieliśmy też, że wyjściowy głośnik będzie w większości przypadków niewielkim głośnikiem telefonu lub tabletu, więc dźwięk musi być ograniczony w spektrum częstotliwości, aby uniknąć zniekształcania głośników. Okazało się to nie lada wyzwaniem. Kiedy otrzymaliśmy od Giorgia pierwsze wersje robocze utworów, pocieszyliśmy się, bo jego kompozycja idealnie współpracowała z tworzonymi przez nas dźwiękami.

Dźwięk silnika

Największym wyzwaniem w programowaniu dźwięków było znalezienie najlepszego brzmienia silnika i ukształtowanie jego działania. Tor wyścigowy przypominał tor Formuły 1 lub Nascar, więc samochody musiały być szybkie i wybuchowe. W tym samym czasie samochody były bardzo małe, więc dźwięk dużego silnika nie łączyłby dźwięku z obrazem. I tak nie udało nam się w ogóle mieć ogromnego ryku z głośnika mobilnego, więc musieliśmy znaleźć coś innego.

Aby zainspirować się naszą kolekcją modułowych syntezatorów, Jon Ekstrand i zaczęliśmy się bawić. Podobało nam się to, co usłyszeliśmy. Tak brzmiało przy dwóch oscylatorach, dobrych filtrach i LFO.

Sprzęt analogowy został już wcześniej z wielkim sukcesem przebudowany za pomocą interfejsu Web Audio API, więc mieliśmy duże nadzieje i zaczęliśmy tworzyć prosty syntezator na potrzeby Web Audio. Wygenerowany dźwięk będzie najbardziej czuły, ale moc obliczeniową urządzenia będzie obciążać moc obliczeniową urządzenia. Aby treści wizualne działały płynnie, musieliśmy wykazać się niezmierną szczerością, aby zaoszczędzić wszystkie zasoby. Dlatego zmieniliśmy technikę na odtwarzanie próbek audio.

Modułowy syntezator dla silnika

Istnieje kilka technik, które można wykorzystać do wyodrębnienia dźwięku silnika na podstawie próbek. Najpopularniejszym podejściem do gier na konsole jest nakładanie warstwy wielu dźwięków (im więcej, tym lepiej) silnika o różnych prędkościach na minutę (z obciążeniem), a następnie przenikanie i przenikanie między dźwiękami. Następnie dodaj warstwę odgłosów silnika, które tylko przywracają (bez obciążenia) przy tym samym obrocie na minutę i przenikają między nimi. Jeśli przełączysz się prawidłowo, przenikanie się między tymi warstwami będzie brzmieć bardzo realistycznie, ale tylko wtedy, gdy masz dużą liczbę plików dźwiękowych. Krzyżowanie nie może być zbyt szerokie – wtedy brzmi bardzo syntetycznie. Ze względu na to, że musieliśmy uniknąć długiego czasu wczytywania, ta opcja nie była dla nas odpowiednia. Próbowaliśmy z pięcioma lub sześcioma plikami dźwiękowymi na każdą warstwę, ale dźwięk był rozczarowujący. Musieliśmy znaleźć sposób na mniejszą liczbę plików.

Najskuteczniejsze rozwiązanie okazało się następujące:

  • Jeden plik dźwiękowy z akceleracją i zmianą biegów zsynchronizowany z wizualnym przyspieszeniem samochodu, którego zakończeniem jest zaprogramowana pętla o najwyższym tonie lub obr./min. Interfejs Web Audio API bardzo dobrze radzi sobie z precyzyjnym pętlaniem, więc moglibyśmy zrobić to bez zakłóceń i przeskoków.
  • 1 plik dźwiękowy z włączonym zwalnianiem / zmniejszaniem prędkości silnika.
  • I wreszcie jeden plik dźwiękowy, który odtwarza w pętli nieruchomy / nieaktywny dźwięk.

Wygląda tak

Grafika dźwięku silnika

W przypadku pierwszego zdarzenia dotknięcia / przyspieszenia odtwarzaliśmy pierwszy plik od początku. Jeśli odtwarzacz zwolnił tempo, obliczaliśmy czas na podstawie miejsca w pliku dźwiękowym w momencie wydania, tak aby po ponownym włączeniu przepustnicy przeskoczyliśmy we właściwe miejsce w pliku akceleracji po odtworzeniu drugiego (zmniejszając go).

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 „Przepustnica”.

<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 niewielkie pliki dźwiękowe i dobry silnik, zdecydowaliśmy się przejść do kolejnego wyzwania.

Pobieram synchronizację

Razem z Davidem Lindkvistem z 14 wysp zaczęliśmy bardziej szczegółowo szukać rozwiązań, które pozwolą na idealną synchronizację. Podstawy są proste. Urządzenie pyta serwer o czas, uwzględnia opóźnienie sieci, a następnie oblicza lokalne przesunięcie zegara.

syncOffset = localTime - serverTime - networkLatency

Dzięki temu opóźnieniu każde połączone urządzenie używa tej samej koncepcji czasu. Łatwe, prawda? (Ponownie teoretycznie).

Obliczam opóźnienie sieci

Możemy przyjąć, że czas oczekiwania jest połowa czasu potrzebnego na wysłanie żądania i odebranie odpowiedzi z serwera:

networkLatency = (receivedTime - sentTime) × 0.5

Problem z takim założeniem polega na tym, że ruch w obie strony do serwera nie zawsze jest symetryczny, więc żądanie może trwać dłużej niż odpowiedź lub odwrotnie. Im większe opóźnienie sieciowe, tym większy wpływ będzie miała ta asymetria, co spowoduje opóźnienia w odtwarzaniu dźwięków i brak synchronizacji z innymi urządzeniami.

Na szczęście nasz mózg jest tak skonfigurowany, że nie zauważa, że dźwięki są nieco opóźnione. Badania wykazały, że mózg odbiera dźwięki z opóźnieniem od 20 do 30 milisekund. Jednak około 12–15 ms poczujesz efekt opóźnionego sygnału, nawet jeśli nie potrafisz go w pełni „odczuć”. Przeanalizowaliśmy kilka uznanych protokołów synchronizacji czasu i prostsze rozwiązania alternatywne, a następnie próbowaliśmy wdrożyć część z nich w praktyce. W efekcie dzięki infrastrukturze Google o krótkim czasie oczekiwania udało nam się w prosty sposób przedstawić serię żądań i użyć próbki o najkrótszym czasie oczekiwania jako punktu odniesienia.

Walcz o zmianę zegara

Udało się! Mieliśmy ponad 5 urządzeń działających w idealnej synchronizacji, ale tylko przez jakiś czas. Po kilku minutach gry urządzenia się rozchodzą, mimo że zaplanowaliśmy dźwięk na podstawie bardzo dokładnego czasu kontekstu interfejsu Web Audio API. Opóźnienie gromadziło się powoli, z upływem kilku milisekund i początkowo niewykrywalne, ale w efekcie po dłuższej grze muzycznej warstwy muzyczne przestały być zsynchronizowane. Cześć, zegar się przesuwa.

Rozwiązaniem było ponowna synchronizacja co kilka sekund, obliczenie nowego przesunięcia zegara i płynne przesłanie tych danych do harmonogramu audio. Aby zmniejszyć ryzyko istotnych zmian w muzyce spowodowanych opóźnieniami w sieci, postanowiliśmy wygładzić te zmiany, zachowując historię ostatnich przesunięć synchronizacji i obliczając średnią.

Planowanie utworu i zmiana aranżacji

Dzięki interaktywnym dźwiękom nie masz już kontroli nad tym, kiedy zostanie odtworzony dany fragment utworu, ponieważ zmiana bieżącego stanu będzie zależeć od działań użytkownika. Musieliśmy szybko zmieniać aranżacje w utworze, co oznaczało, że nasz algorytm szeregowania musiał być w stanie obliczyć, ile pozostało pozostałości aktualnie odtwarzanego paska, zanim przejdzie na kolejną aranżację. Nasz algorytm wyglądał tak:

  • Client(1) włącza utwór.
  • Client(n) pyta pierwszego klienta, kiedy trafił utwór.
  • Client(n) oblicza punkt odniesienia, który wskazuje czas rozpoczęcia utworu na podstawie kontekstu Web Audio, uwzględniając parametr syncOffset i czas, który upłynął od momentu utworzenia kontekstu audio.
  • playDelta = Date.now() - syncOffset - songStartTime - context.currentTime
  • Client(n) oblicza czas odtwarzania utworu, korzystając z funkcji playDelta. Na podstawie tych danych algorytm szeregowania utworów wie, który takt w bieżącym ustawieniu należy odtworzyć jako następny.
  • playTime = playDelta + context.currentTime nextBar = Math.ceil((playTime % loopDuration) ÷ barDuration) % numberOfBars

Dla zdrowego rozsądku ograniczyliśmy aranżacje tak, aby zawsze trwały osiem taktów i mieliśmy identyczne tempo (uderzenia na minutę).

Spójrz przed siebie

Jeśli używasz setTimeout lub setInterval w JavaScript, zawsze warto zaplanować spotkanie z wyprzedzeniem. Dzieje się tak, ponieważ zegar JavaScript nie jest bardzo precyzyjny, a zaplanowane wywołania zwrotne mogą zostać zniekształcone o kilkadziesiąt milisekund lub więcej ze względu na układ, renderowanie, odśmiecanie i żądania XMLHTTPRequests. W naszym przypadku musieliśmy także brać pod uwagę czas potrzebny na otrzymanie tego samego zdarzenia przez wszystkich klientów w sieci.

sprite’y audio,

Połączenie dźwięków w jednym pliku to świetny sposób na ograniczenie żądań HTTP zarówno w przypadku HTML Audio, jak i interfejsu Web Audio API. Jest to również najlepszy sposób responsywnego odtwarzania dźwięków za pomocą obiektu Audio, ponieważ nie wymaga ładowania nowego obiektu audio przed rozpoczęciem odtwarzania. Istnieje już kilka dobrych implementacji, których użyjesz jako punktu wyjścia. Rozszerzyliśmy nasze funkcje sprite tak, aby działały niezawodnie zarówno na iOS, jak i na Androidzie. Poradziły też sobie z nietypowymi przypadkami, w których urządzenia zasypiają.

Na urządzeniach z Androidem elementy audio odtwarzają się nawet po przełączeniu urządzenia w tryb uśpienia. W trybie uśpienia wykonywanie JavaScriptu jest ograniczone, aby oszczędzać baterię, i nie można polegać na uruchamianie wywołań zwrotnych przez requestAnimationFrame, setInterval ani setTimeout. Jest to problem, ponieważ sprite'y audio wykorzystują JavaScript, aby sprawdzać, czy odtwarzanie powinno zostać zatrzymane. W niektórych przypadkach funkcja currentTime elementu audio nie jest aktualizowana, chociaż dźwięk wciąż się odtwarza.

Sprawdź implementację AudioSprite, której użyliśmy w Chrome Racer jako kreacji zastępczej nieobjętej Web Audio.

Element dźwiękowy

Gdy rozpoczęliśmy pracę nad Racer, Chrome na Androida nie obsługiwał jeszcze interfejsu Web Audio API. Logika korzystania z HTML Audio na niektórych urządzeniach, a w innych – interfejsu Web Audio API w połączeniu z zaawansowanymi wyjściami audio, które miały nam pomóc w osiągnięciu interesujących wyzwań. Na szczęście to wszystko w historii. Interfejs Web Audio API jest wdrożony na Androidzie M28 w wersji beta.

  • Problemy z opóźnieniami lub terminami. Element audio nie zawsze jest odtwarzany dokładnie wtedy, gdy mu go wskażesz. Ponieważ kod JavaScript jest jednowątkowy, przeglądarka może być zajęta, co może spowodować opóźnienie odtwarzania nawet do dwóch sekund.
  • Opóźnienia odtwarzania oznaczają, że płynne zapętlenie nie zawsze jest możliwe. Na komputerze możesz użyć podwójnego buforowania, aby uzyskać pętle bez przerw, ale na urządzeniach mobilnych jest to niemożliwe, ponieważ:
    • Większość urządzeń mobilnych nie odtwarza jednocześnie więcej niż 1 elementu audio.
    • 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 zacznie ładować swoje źródło dopiero wtedy, gdy rozpocznie się odtwarzanie w module obsługi touchStart.
  • Wyszukiwanie problemów. Pobranie duration lub ustawienie currentTime nie uda się, jeśli Twój serwer nie obsługuje zakresu bajtów HTTP. Zwróć uwagę na ten element, jeśli tak jak my tworzysz takiego sprite’a.
  • Podstawowe uwierzytelnianie w formacie MP3 kończy się niepowodzeniem. Niektóre urządzenia nie mogą wczytać plików MP3 chronionych uwierzytelnianiem podstawowym niezależnie od używanej przeglądarki.

Podsumowanie

Przeszliśmy długą drogę od momentu kliknięcia przycisku wyciszenia jako najlepszego sposobu radzenia sobie z dźwiękiem w internecie, ale to dopiero początek, a dźwięk w internecie za chwilę będzie ciężki. Dotarliśmy do sedna sprawy, jeśli chodzi o synchronizację wielu urządzeń. Nie mieliśmy mocy obliczeniowej w telefonach i tabletach, żeby zagłębić się w przetwarzanie sygnałów i efekty (takie jak pogłos), ale wraz ze wzrostem wydajności urządzenia gry internetowe będą korzystać z tych funkcji. To ekscytująca chwila, w której można dalej rozwijać możliwości dźwięku.