Wzorzec projektu Worklet audio

Podstawowe pojęcia i zastosowania zostały opisane w poprzednim artykule na temat usługi Audio Worklet. Od momentu premiery w Chrome 66 pojawiło się wiele próśb o podanie więcej przykładów jego zastosowania w rzeczywistych aplikacjach. Worklet Audio pozwala w pełni wykorzystać potencjał WebAudio, ale zastosowanie go może być wyzwaniem, ponieważ wymaga znajomości jednoczesnego programowania połączonych z kilkoma interfejsami API JavaScript. Nawet dla programistów, którzy znają WebAudio, integracja Worklet Audio z innymi interfejsami API (np. WebAssembly) może być trudna.

W tym artykule czytelnicy lepiej zrozumieją, jak używać Workletu audio w rzeczywistych warunkach, oraz przedstawi wskazówki, które pozwolą w pełni wykorzystać jego możliwości. Zachęcamy też do zapoznania się z przykładami kodu i prezentacjami na żywo.

Podsumowanie: Worklet audio

Zanim przejdziemy do sedna, podsumujmy krótko pojęcia i fakty dotyczące systemu Worklet audio, które omówiliśmy wcześniej w tym poście.

  • BaseAudioContext: podstawowy obiekt interfejsu Web Audio API.
  • Worklet audio: specjalny program do ładowania plików skryptów na potrzeby operacji audio Worklet. Należy do BaseAudioContext. Element BaseAudioContext może mieć jeden Worklet audio. Wczytywany plik skryptu jest oceniany w AudioWorkletGlobalScope i służy do tworzenia instancji AudioWorkletProcessor.
  • AudioWorkletGlobalScope: specjalny zakres globalny JS na potrzeby operacji Worklet audio. Uruchamia się w specjalnym wątku renderowania dla WebAudio. Element BaseAudioContext może mieć jeden AudioWorkletGlobalScope.
  • AudioWorkletNode: węzeł audio przeznaczony do operacji audio Worklet. Powstał z BaseAudioContext. Element BaseAudioContext może mieć wiele węzłów AudioWorkletNodes podobnie jak natywne węzły audio.
  • AudioWorkletProcessor: odpowiednik obiektu AudioWorkletNode. Rzeczywiste wnętrze AudioWorkletNode przetwarzającego strumień audio za pomocą kodu podanego przez użytkownika. Tworzony jest element AudioWorkletNode w obiekcie AudioWorkletGlobalScope. Węzeł AudioWorkletNode może mieć jeden pasujący procesor audioWorkletProcess.

Wzory projektowe

Używanie Worklet audio z WebAssembly

Pakiet WebAssembly jest idealnym towarzyszem audioWorkletProcessor. Połączenie tych 2 funkcji daje szereg zalet przetwarzania dźwięku w internecie. Dwie najważniejsze to: a) uwzględnienie istniejącego kodu przetwarzania dźwięku C/C++ w ekosystemie WebAudio oraz b) uniknięcie obciążenia związanego z kompilacją JS JIT i zbieraniem śmieci w kodzie przetwarzania dźwięku.

Pierwszy z nich jest ważny dla programistów, którzy inwestują już w kod i biblioteki przetwarzania dźwięku, ale drugi ma kluczowe znaczenie dla niemal wszystkich użytkowników interfejsu API. W świecie WebAudio budżet czasowy stabilnych strumieni audio jest dość duży: wynosi zaledwie 3 ms przy częstotliwości próbkowania 44, 1 kHz. Nawet niewielkie błędy w kodzie przetwarzania dźwięku mogą powodować zakłócenia. Programista musi zoptymalizować kod, aby przyspieszyć przetwarzanie, a jednocześnie zminimalizować ilość generowanych operacji czyszczenia JS. WebAssembly może być rozwiązaniem, które rozwiązuje oba problemy jednocześnie: jest szybsze i nie generuje zanieczyszczenia kodu.

W następnej sekcji opisujemy, jak można używać WebAssembly z Workletem audio. Przykładowy kod znajdziesz tutaj. Podstawowy samouczek korzystania z Emscripten i WebAssembly (a zwłaszcza kodu kleju Emscripten) znajdziesz w tym artykule.

Konfigurowanie

Wszystko brzmi świetnie, ale do ich poprawnego skonfigurowania potrzeba trochę struktury. Najpierw trzeba zadać sobie pytanie, jak i gdzie utworzyć instancję modułu WebAssembly. Po pobraniu kodu kleju Emscripten dostępne są 2 ścieżki do utworzenia instancji modułu:

  1. Utwórz instancję modułu WebAssembly, wczytując kod kleju do AudioWorkletGlobalScope za pomocą audioContext.audioWorklet.addModule().
  2. Utwórz instancję modułu WebAssembly w głównym zakresie, a następnie prześlij go za pomocą opcji konstruktora AudioWorkletNode.

Decyzja w dużej mierze zależy od projektu i preferencji, ale chodzi o to, że moduł WebAssembly może wygenerować instancję WebAssembly w AudioWorkletGlobalScope, która staje się jądrem przetwarzania dźwięku w instancji AudioWorkletProcessor.

Wzorzec tworzenia instancji modułu WebAssembly A: użycie wywołania .addModule()
Wzorzec tworzenia instancji A modułu WebAssembly: używany jest wywołanie .addModule()

Aby wzorzec A działał prawidłowo, Emscripten wymaga kilku opcji w celu wygenerowania prawidłowego kodu kleju WebAssembly dla naszej konfiguracji:

-s BINARYEN_ASYNC_COMPILATION=0 -s SINGLE_FILE=1 --post-js mycode.js

Te opcje zapewniają synchroniczną kompilację modułu WebAssembly w AudioWorkletGlobalScope. Dołącza on też definicję klasy AudioWorkletProcessor w elemencie mycode.js, aby można ją było wczytać po zainicjowaniu modułu. Głównym powodem użycia kompilacji synchronicznej jest to, że obiecujące rozwiązanie funkcji audioWorklet.addModule() nie oczekuje na rozwiązanie obietnic w obiekcie AudioWorkletGlobalScope. Synchroniczne wczytywanie lub kompilacja w wątku głównym nie jest zwykle zalecane, ponieważ blokuje inne zadania w tym samym wątku. Można jednak pominąć tę regułę, ponieważ kompilacja odbywa się w elemencie AudioWorkletGlobalScope, który działa poza wątkiem głównym. Aby dowiedzieć się więcej, przeczytaj ten artykuł.

Wzorzec tworzenia instancji modułu WASM (B): wykorzystanie transferu konstruktora AudioWorkletNode między wątkiem
Wzorzec instancji B modułu WASM: użycie transferu między wątkami konstruktora AudioWorkletNode

Wzorzec B może być przydatny, jeśli wymagane jest asynchroniczne podnoszenie ciężarów. Wykorzystuje wątek główny do pobierania kodu kleju z serwera i kompilowania modułu. Następnie przesyła moduł WASM za pomocą konstruktora AudioWorkletNode. Ten wzorzec ma jeszcze bardziej sens, gdy trzeba dynamicznie ładować moduł, gdy AudioWorkletGlobalScope zacznie renderować strumień audio. W zależności od rozmiaru modułu skompilowanie go w trakcie renderowania może spowodować zakłócenia w strumieniu.

Dane sterty i dźwięku WASM

Kod WebAssembly działa tylko w pamięci przydzielonej w oddzielnej stercie WASM. Aby korzystać z tej funkcji, trzeba klonować dane dźwiękowe między stertą WASM a tablicami danych audio. Klasa HeapAudioBuffer w przykładowym kodzie dobrze obsługuje tę operację.

Klasa HeapAudioBuffer ułatwiająca korzystanie ze sterty WASM
Klasa HeapAudioBuffer ułatwiająca wykorzystanie sterty WASM

Obecnie przygotowujemy wczesną propozycję integracji stosu WASM bezpośrednio z systemem Audio Worklet. Usunięcie nadmiarowych klonowania danych między pamięcią JS a stertą WASM wydaje się naturalne, ale trzeba dopracować szczegóły.

Niezgodność rozmiaru bufora

Para AudioWorkletNode i AudioWorkletProcessor została zaprojektowana tak, aby działać jak standardowy element AudioNode. AudioWorkletNode obsługuje interakcję z innymi kodami, natomiast AudioWorkletProcessor odpowiada za wewnętrzne przetwarzanie dźwięku. Zwykły AudioNode przetwarza 128 klatek jednocześnie, więc funkcja AudioWorkletProcessor, która może być jego podstawową funkcją, musi robić to samo. Jest to jedna z zalet konstrukcji Worklet audio, która eliminuje dodatkowe opóźnienia wynikające z wewnętrznego buforowania w AudioWorkletProcessor, ale może stanowić problem, jeśli funkcja przetwarzania wymaga bufora innego niż 128 klatek. Typowym rozwiązaniem w takim przypadku jest użycie bufora pierścieniowego, znanego również jako okrągły bufor lub FIFO.

Oto schemat elementu AudioWorkletProcessor, w którym zastosowano 2 bufory pierścieniowe do wykorzystania w funkcji WASM, która pobiera i wyprowadza 512 klatek. (numer 512 jest w tym przypadku wybierany losowo).

Zastosowanie RingBuffer w metodzie „process()” AudioWorkletProcessor
Użycie RingBuffer w metodzie „process()” AudioWorkletProcessor

Algorytm na diagramie wyglądałby tak:

  1. AudioWorkletProcessor przesyła 128 klatek do elementu Input RingBuffer ze swojego wejścia.
  2. Wykonaj poniższe czynności tylko wtedy, gdy pierścień wejściowego zawiera co najmniej 512 klatek.
    1. Pobierz 512 klatek z pierścienia wejściowego.
    2. Przetwórz 512 klatek z daną funkcją WASM.
    3. Wypchnij 512 klatek do Bufora wyjściowego.
  3. AudioWorkletProcessor pobiera 128 klatek z pierścienia wyjściowego, aby wypełnić pole output.

Jak pokazano na schemacie, ramki wejściowe są zawsze gromadzone w Buforze danych wejściowych i zajmują się przepełnieniem bufora, zastępując najstarszy blok klatek w buforze. Jest to uzasadnione w przypadku aplikacji do odtwarzania dźwięku w czasie rzeczywistym. I podobnie, blok ramki wyjściowej jest zawsze pobierany przez system. Niedostateczny poziom bufora (za mało danych) w wyjściowym buforze RingBuffer spowoduje wyciszenie i zakłócenie w strumieniu.

Ten wzorzec jest przydatny podczas zastępowania ScriptProcessorNode (SPN) elementem AudioWorkletNode. Ponieważ SPN pozwala deweloperowi wybrać rozmiar bufora z zakresu od 256 do 16 384 klatek, zastępowanie SPN przez AudioWorkletNode może być trudne i przyjemnym rozwiązaniem jest użycie bufora pierścieniowego. Świetnym przykładem może być nagrywarka dźwięku.

Pamiętaj jednak, że taki projekt uwzględnia jedynie niezgodność rozmiaru bufora i nie daje więcej czasu na uruchomienie danego kodu skryptu. Jeśli kod nie może ukończyć zadania w ramach budżetu czasowego kwantowego renderowania (ok.3 ms przy 44,1 kHz), wpłynie to na czas rozpoczęcia późniejszej funkcji wywołania zwrotnego, a ostatecznie spowoduje błędy.

Połączenie tego projektu z WebAssembly może być skomplikowane z powodu zarządzania pamięcią wokół sterty WASM. W momencie tworzenia tego tekstu należy sklonować dane wchodzące na stertę i poza nią w formacie WASM, ale możemy użyć klasy HeapAudioBuffer, aby nieco ułatwić zarządzanie pamięcią. Idea wykorzystania pamięci przydzielonej przez użytkowników do zmniejszenia nadmiarowego klonowania danych zostanie omówiona w przyszłości.

Klasa RingBuffer znajdziesz tutaj.

WebAudio Powerhouse: Worklet audio i SharedArrayBuffer

Ostatnim wzorcem projektowania zawartym w tym artykule jest umieszczenie w jednym miejscu kilku najbardziej zaawansowanych interfejsów API: Audio Worklet, SharedArrayBuffer, Atomics i Worker. Ta nieprosta konfiguracja otwiera ścieżkę dla oprogramowania audio napisanego w C/C++, aby działać w przeglądarce bez problemów.

Omówienie ostatniego wzorca projektowego: Audio Worklet, SharedArrayBuffer i Worker
Przegląd ostatniego wzorca projektowego: Audio Worklet, SharedArrayBuffer i Worker

Największą zaletą tego projektu jest możliwość użycia wyłącznie DedicatedWorkerGlobalScope do przetwarzania dźwięku. W Chrome instancja WorkerGlobalScope działa w wątku o niższym priorytecie niż wątek renderowania WebAudio, ale ma kilka zalet w porównaniu z klasą AudioWorkletGlobalScope. Interfejs DedicatedWorkerGlobalScope jest mniej ograniczony pod względem powierzchni interfejsu API dostępnej w zakresie. Z kolei Emscripten zapewnia lepsze wsparcie dzięki temu interfejsowi Worker API istnieje już od kilku lat.

Element SharedSlateBuffer odgrywa kluczową rolę w sprawnym działaniu tego projektu. Mimo że instancja robocza i AudioWorkletProcessor obsługują asynchroniczne przesyłanie wiadomości (MessagePort), nie są optymalne w przypadku przetwarzania dźwięku w czasie rzeczywistym ze względu na powtarzalne przydzielanie pamięci i opóźnienia przesyłania wiadomości. Dlatego z góry przydzielamy blok pamięci, który jest dostępny z obu wątków, aby można było szybko dwukierunkowe przesyłanie danych.

Z perspektywy purystycznego interfejsu Web Audio API może on wydawać się nieoptymalny, ponieważ wykorzystuje Worklet Audio jako proste „ujście audio” i robi wszystko, co jest w obrębie instancji roboczej. Biorąc jednak pod uwagę koszt przeredagowania projektów C/C++ w języku JavaScript może być zbyt trudny lub nawet niemożliwy, taki projekt może być najskuteczniejszą ścieżką implementacji w przypadku takich projektów.

Współdzielone stany i Atomics

Jeśli używasz pamięci współdzielonej na potrzeby danych audio, dostęp z obu stron należy starannie skoordynować. Udostępnianie stanów dostępności atomowej jest rozwiązaniem tego problemu. W tym celu możemy skorzystać z Int32Array wspieranego przez SAB.

Mechanizm synchronizacji: SharedTrackBuffer i Atomics
Mechanizm synchronizacji: SharedArrayBuffer i Atomics

Mechanizm synchronizacji: SharedTrackBuffer i Atomics

Każde pole tablicy stanów zawiera istotne informacje o buforach współdzielonych. Najważniejszym z nich jest pole synchronizacji (REQUEST_RENDER). Oznacza to, że instancja robocza czeka na dotknięcie tego pola przez AudioWorkletProcessor i przetwarza dźwięk, gdy się wybudzi. Ten mechanizm umożliwia nie tylko korzystanie z SharedTrackBuffer (SAB), jak i interfejs Atomics API.

Pamiętaj, że synchronizacja 2 wątków jest raczej swobodna. Rozpoczęcie działania Worker.process() zostanie aktywowane przez metodę AudioWorkletProcessor.process(), ale AudioWorkletProcessor nie czeka na zakończenie działania Worker.process(). Jest to celowe. Procesor AudioWorkletProcessor jest sterowany przez wywołanie zwrotne audio, więc nie może być blokowany synchronicznie. W najgorszym przypadku, gdy strumień audio może zostać zduplikowany lub przestanie się pojawiać, ale w końcu powróci po ustabilizowaniu się wydajności renderowania.

Konfiguracja i uruchamianie

Jak widać na powyższym diagramie, ten projekt składa się z kilku komponentów do rozmieszczenia: DedicatedWorkerGlobalScope (DWGS), AudioWorkletGlobalScope (AWGS), SharedTrackBuffer i wątek główny. Poniżej opisujemy, co powinno się dziać na etapie inicjowania.

Zdarzenie inicjujące
  1. [Główne] Wywoływany jest konstruktor AudioWorkletNode.
    1. Utwórz instancję roboczą.
    2. Zostanie utworzony powiązany procesor AudioWorkletProcess.
  2. [DWGS] Instancja robocza tworzy 2 obiekty SharedArrayBuffer. (jeden dla stanów udostępnionych, a drugi dla danych audio)
  3. [DWGS] Instancja robocza wysyła odwołania do zasobu SharedArrayBuffer do AudioWorkletNode.
  4. [Główne] AudioWorkletNode wysyła odwołania do klasy SharedArrayBuffer do AudioWorkletProcessor.
  5. [AWGS] AudioWorkletProcessor powiadamia system AudioWorkletNode o ukończeniu konfiguracji.

Po zakończeniu inicjowania zaczyna się wywoływać AudioWorkletProcessor.process(). Oto, co powinno się wydarzyć w każdej iteracji pętli renderowania.

Pętla renderowania
Renderowanie wielowątkowe z użyciem SharedArrayBuffers
Renderowanie wielowątkowe z użyciem SharedSlateBuffers
  1. [AWGS] Parametr AudioWorkletProcessor.process(inputs, outputs) jest wywoływany w przypadku każdego kwantu renderowania.
    1. Element inputs zostanie przekazany do SAB.
    2. Pole outputs zostanie uzupełnione na podstawie danych audio w Wyjściu SAB.
    3. odpowiednio aktualizuje States SAB o nowe indeksy bufora.
    4. Gdy wyjściowa SAB zbliży się do progu niedomiarowego, Wake Worker wyrenderuje więcej danych audio.
  2. [DWGS] Pracownik czeka (uśpiony) na sygnał wybudzania z AudioWorkletProcessor.process(). Po wybudzeniu:
    1. Pobiera indeksy bufora ze States SAB.
    2. Uruchom funkcję procesu zawierającą dane z wejściowego SAB, aby wypełnić wyjściowy obszar SAB.
    3. odpowiednio aktualizuje States SAB o indeksy bufora.
    4. Zasypia i czeka na następny sygnał.

Przykładowy kod można znaleźć tutaj, ale pamiętaj, że aby ta wersja demonstracyjna działała, musi być włączona flaga eksperymentalna SharedSlateBuffer. Dla uproszczenia napisano go czystym kodem JS, ale w razie potrzeby można go zastąpić kodem WebAssembly. Tego typu przypadek należy traktować ze szczególną ostrożnością, dodając do zarządzania pamięcią klasę HeapAudioBuffer.

Podsumowanie

Podstawowym celem Worklet Audio jest to, aby interfejs Web Audio API był naprawdę „rozszerzalny”. Wieloletnie starania pracowaliśmy nad jego projektem, by umożliwić wdrożenie pozostałej części interfejsu Web Audio API z użyciem Audio Worklet. Teraz projekt jest bardziej złożony, co może stanowić nieoczekiwane wyzwanie.

Na szczęście taka złożoność wynika ze złożoności, jaką daje programistom. Możliwość uruchomienia WebAssembly w projekcie AudioWorkletGlobalScope daje ogromne możliwości wysokiej wydajności przetwarzania dźwięku w internecie. W przypadku dużych aplikacji audio napisanych w języku C lub C++ atrakcyjną opcją może być użycie Worklet Audio z funkcjami SharedArrayBuffers i Workers.

Środki

Szczególnie dziękujemy Chrisowi Wilsonowi, Jasonowi Millerowi, Joshua Bell i Raymondowi Toyowi za sprawdzenie wersji roboczej tego artykułu i przekazanie wnikliwych opinii.