W poprzednim artykule na temat modułu audio omówiliśmy podstawowe pojęcia i sposób korzystania z tego modułu. Od czasu wprowadzenia tej funkcji w Chrome 66 otrzymaliśmy wiele próśb o pokazanie więcej przykładów jej zastosowania w rzeczywistych aplikacjach. Worklet Audio pozwala w pełni wykorzystać potencjał WebAudio, ale korzystanie z niego może być trudne, ponieważ wymaga zrozumienia programowania równoległego w ramach kilku interfejsów JS API. Nawet deweloperzy, którzy znają WebAudio, mogą mieć problemy z integracją modułu Audio Worklet z innymi interfejsami API (np. WebAssembly).
Z tego artykułu dowiesz się, jak korzystać z workleta audio w rzeczywistych warunkach i jak w pełni wykorzystać jego możliwości. Sprawdź też przykłady kodu i prezentacje na żywo.
Podsumowanie: element audio Worklet
Zanim przejdziemy do szczegółów, przypomnijmy sobie terminy i informacje dotyczące systemu Audio Worklet, który został wcześniej przedstawiony w tym poście.
- BaseAudioContext główny obiekt interfejsu Web Audio API.
- Audio Worklet: specjalny moduł wczytywania plików skryptów do operacji Audio Worklet. Należy do BaseAudioContext. BaseAudioContext może zawierać 1 element Audio Worklet. Załadowany plik skryptu jest oceniany w ramach AudioWorkletGlobalScope i używany do tworzenia instancji AudioWorkletProcessor.
- AudioWorkletGlobalScope: specjalny globalny zakres JS dla operacji Worklet audio. Działa na osobnym wątku renderowania dla WebAudio. Element BaseAudioContext może mieć jeden element AudioWorkletGlobalScope.
- AudioWorkletNode: węzeł AudioNode zaprojektowany do operacji Audio Worklet. Tworzony na podstawie klasy BaseAudioContext. BaseAudioContext może zawierać wiele węzłów AudioWorkletNodes, podobnie jak natywne węzły AudioNodes.
- AudioWorkletProcessor: odpowiednik węzła AudioWorkletNode. Rzeczywista część węzła AudioWorkletNode, która przetwarza strumień audio za pomocą kodu dostarczonego przez użytkownika. Jest on instancjowany w AudioWorkletGlobalScope, gdy tworzy się węzeł AudioWorkletNode. Element AudioWorkletNode może mieć 1 element AudioWorkletProcessor.
Design Patterns
Korzystanie z elementu Audio Worklet w standardzie WebAssembly
WebAssembly to idealny towarzysz dla AudioWorkletProcessor. Połączenie tych dwóch funkcji zapewnia wiele korzyści w zakresie przetwarzania dźwięku w internecie, ale największe z nich to: a) wprowadzenie do ekosystemu WebAudio dotychczasowego kodu przetwarzania dźwięku w języku C/C++, oraz b) uniknięcie obciążeń związanych z kompilacją JIT w JS i zbieraniem zbędnych danych w kodzie przetwarzania dźwięku.
Pierwsza jest ważna dla deweloperów, którzy mają już zainwestowane w kod i biblioteki do przetwarzania dźwięku, ale druga jest kluczowa dla prawie wszystkich użytkowników interfejsu API. W świecie WebAudio budżet czasowy dla stabilnego strumienia audio jest dość wymagający: wynosi tylko 3 ms przy częstotliwości próbkowania 44, 1 kHz. Nawet niewielki błąd w kodzie do przetwarzania dźwięku może powodować zakłócenia. Deweloperzy muszą zoptymalizować kod pod kątem szybszego przetwarzania, ale też zminimalizować ilość generowanego kodu JS. WebAssembly może być rozwiązaniem, które rozwiązuje oba te problemy jednocześnie: jest szybsze i nie generuje niepotrzebnego kodu.
W następnej sekcji opisujemy, jak używać WebAssembly w ramach Audio Worklet. Przykładowy kod znajdziesz tutaj. Podstawowy samouczek na temat używania Emscripten i WebAssembly (zwłaszcza kodu łączącego Emscripten) znajdziesz w tym artykule.
Konfigurowanie
Wszystko brzmi świetnie, ale potrzebujemy trochę struktury, aby wszystko prawidłowo skonfigurować. Pierwsze pytanie projektowe, jakie należy zadać, to jak i gdzie utworzyć instancję modułu WebAssembly. Po pobraniu kodu łączącego Emscripten istnieją 2 ścieżki do utworzenia modułu:
- Tworzenie instancji modułu WebAssembly przez załadowanie kodu łączącego do AudioWorkletGlobalScope za pomocą
audioContext.audioWorklet.addModule()
. - Utwórz instancję modułu WebAssembly w zakresie głównym, a potem prześlij moduł za pomocą opcji konstruktora AudioWorkletNode.
Ta decyzja zależy głównie od projektu i preferencji, ale chodzi o to, aby moduł WebAssembly mógł wygenerować instancję WebAssembly w AudioWorkletGlobalScope, która staje się rdzeniem przetwarzania dźwięku w instancji AudioWorkletProcessor.
Aby wzór A działał prawidłowo, Emscripten potrzebuje kilku opcji do wygenerowania odpowiedniego kodu klejącego WebAssembly dla naszej konfiguracji:
-s BINARYEN_ASYNC_COMPILATION=0 -s SINGLE_FILE=1 --post-js mycode.js
Te opcje zapewniają kompilację modułu WebAssembly w ramach AudioWorkletGlobalScope w sposób synchroniczny. Dodaje też definicję klasy AudioWorkletProcessor w funkcji mycode.js
, aby można było ją załadować po zainicjowaniu modułu.
Głównym powodem korzystania z kompilacji synchronicznej jest to, że rozwiązanie obietnicy audioWorklet.addModule()
nie czeka na rozwiązanie obietnic w AudioWorkletGlobalScope. Ładowanie lub kompilacja w głównym wątku nie jest ogólnie zalecana, ponieważ blokuje inne zadania w tym samym wątku, ale w tym przypadku możemy ominąć tę regułę, ponieważ kompilacja odbywa się w ramach AudioWorkletGlobalScope, który działa poza głównym wątkiem. (Więcej informacji znajdziesz tutaj).
Wzór B może być przydatny, jeśli wymagane jest asynchroniczne przetwarzanie dużych ilości danych. Wykorzystuje on główny wątek do pobierania kodu łączącego z serwera i kompilowania modułu. Następnie przekaże moduł WASM za pomocą konstruktora AudioWorkletNode. Ten wzór ma jeszcze większy sens, gdy musisz wczytać moduł dynamicznie po rozpoczęciu przez AudioWorkletGlobalScope renderowania strumienia audio. W zależności od rozmiaru modułu jego kompilacja w trakcie renderowania może powodować zakłócenia strumienia.
Stos i dane audio w WASM
Kod WebAssembly działa tylko w pamięci przydzielonej w ramach dedykowanego stosu WASM. Aby z niego korzystać, dane audio muszą być klonowane między stosem WASM a tablicami danych audio. Klasa HeapAudioBuffer w przykładowym kodzie obsługuje tę operację.
W trakcie jest wstępna propozycja, która zakłada zintegrowanie stosu pamięci WASM bezpośrednio z systemem Audio Worklet. Pozbycie się tego zbędnego klonowania danych między pamięcią JS a kupą WASM wydaje się naturalne, ale trzeba jeszcze dopracować szczegóły.
Rozwiązywanie problemu niezgodności rozmiaru bufora
Para AudioWorkletNode i AudioWorkletProcessor działa jak zwykły węzeł AudioNode. Węzeł AudioWorkletNode obsługuje interakcję z innymi kodami, a węzeł AudioWorkletProcessor zajmuje się wewnętrznym przetwarzaniem dźwięku. Ponieważ zwykły węzeł AudioNode przetwarza 128 ramek naraz, AudioWorkletProcessor musi robić to samo, aby stać się funkcją podstawową. Jest to jedna z zalet projektu modułu Audio Worklet, który zapewnia, że w AudioWorkletProcessor nie jest wprowadzane żadne dodatkowe opóźnienie z powodu buforowania wewnętrznego, ale może to być problemem, jeśli funkcja przetwarzania wymaga rozmiaru bufora innego niż 128 ramek. Typowym rozwiązaniem w takim przypadku jest użycie pierścieniowego bufora, zwanego też pętlą lub FIFO.
Oto diagram AudioWorkletProcessor, który używa 2 buforów pierścieniowych do obsługi funkcji WASM, która przyjmuje 512 klatek na wejściu i wyjściu. (Liczba 512 została wybrana arbitralnie).
Algorytm na diagramie:
- AudioWorkletProcessor przesyła 128 ramek do bufora wejściowego z wejścia.
- Wykonaj te czynności tylko wtedy, gdy bufor wejściowy typu RingBuffer zawiera co najmniej 512 ramek.
- Pobierz 512 ramek z bufora wejściowego RingBuffer.
- Przetworzenie 512 ramek za pomocą podanej funkcji WASM.
- Prześlij 512 ramek do bufora wyjściowego RingBuffer.
- AudioWorkletProcessor pobiera 128 ramek z bufora wyjściowego, aby wypełnić dane wyjściowe.
Jak widać na diagramie, ramki wejściowe są zawsze gromadzone w pierścieniu buforowym wejścia, które obsługuje przepełnienie bufora przez nadpisanie najstarszego bloku ramki w buforze. To rozsądne rozwiązanie w przypadku aplikacji do przetwarzania dźwięku w czasie rzeczywistym. Podobnie blok ramki wyjściowej będzie zawsze pobierany przez system. Przepełnienie bufora (niewystarczająca ilość danych) w buforze wyjściowym RingBuffer spowoduje ciszę, co wywoła zakłócenie strumienia.
Ten wzór jest przydatny podczas zastępowania węzła ScriptProcessorNode (SPN) węzłem AudioWorkletNode. Ponieważ SPN pozwala deweloperowi wybrać rozmiar bufora w zakresie od 256 do 16 384 ramek, zastąpienie SPN węzłem AudioWorkletNode może być trudne, a używanie pierścieniowego bufora stanowi dobre rozwiązanie zastępcze. Przykładem funkcji, którą można zbudować na podstawie tego projektu, jest nagrywarka dźwięku.
Pamiętaj jednak, że ta konstrukcja tylko rozwiązuje problem niezgodności rozmiaru bufora i nie daje więcej czasu na wykonanie danego kodu skryptu. Jeśli kod nie może ukończyć zadania w ramach budżetu czasowego renderowania kwantu (~3 ms przy 44,1 kHz), wpłynie to na czas rozpoczęcia kolejnej funkcji wywołania i ostatecznie spowoduje błędy.
Połączenie tego rozwiązania z WebAssembly może być skomplikowane ze względu na zarządzanie pamięcią w przypadku stosu WASM. W momencie pisania tego tekstu dane wchodzące i wychodzące z pamięci zbiorowej WASM muszą być sklonowane, ale możemy użyć klasy HeapAudioBuffer, aby nieco ułatwić zarządzanie pamięcią. Koncepcja wykorzystania pamięci przydzielonej przez użytkownika w celu zmniejszenia zbędnego powielania danych zostanie omówiona w przyszłości.
Klasę RingBuffer znajdziesz tutaj.
WebAudio Powerhouse: moduł Audio Worklet i obiekt SharedArrayBuffer
Ostatni wzór projektowania w tym artykule polega na umieszczeniu w jednym miejscu kilku najnowocześniejszych interfejsów API: Audio Worklet, SharedArrayBuffer, Atomics i Worker. Dzięki temu nieoczywistym ustawieniom można uruchomić w przeglądarce istniejące oprogramowanie audio napisane w C/C++, zapewniając przy tym płynne działanie.
Największą zaletą tego rozwiązania jest możliwość korzystania z DedicatedWorkerGlobalScope wyłącznie do przetwarzania dźwięku. W Chrome WorkerGlobalScope działa na wątku o niższym priorytecie niż wątek renderowania WebAudio, ale ma kilka zalet w porównaniu z AudioWorkletGlobalScope. Zakres globalny w ramach wątku dedykowanego jest mniej ograniczony pod względem interfejsu API dostępnego w tym zakresie. Możesz też oczekiwać lepszego wsparcia ze strony Emscripten, ponieważ interfejs Worker API istnieje już od kilku lat.
Obiekt SharedArrayBuffer odgrywa kluczową rolę w tym, aby ta architektura działała wydajnie. Zarówno Worker, jak i AudioWorkletProcessor są wyposażone w asynchroniczne przesyłanie wiadomości (MessagePort), ale nie jest to optymalne rozwiązanie do przetwarzania dźwięku w czasie rzeczywistym z powodu powtarzającej się alokacji pamięci i opóźnień w przesyłaniu wiadomości. Dlatego na początku przydzielamy blok pamięci, do którego można uzyskać dostęp z obu wątków, aby umożliwić szybki dwukierunkowy transfer danych.
Z punktu widzenia zwolenników czystego Web Audio API ta konstrukcja może wydawać się nieoptymalna, ponieważ wykorzystuje moduł Audio Worklet jako proste „wyjście audio” i wykonuje wszystkie operacje w Workerze. Jednak biorąc pod uwagę, że koszt przepisania projektów C/C++ na JavaScript może być zbyt wysoki lub wręcz niemożliwy, ta metoda może być najskuteczniejszym sposobem wdrożenia takich projektów.
Stany współdzielone i atomowe
W przypadku korzystania z wspólnej pamięci na potrzeby danych audio dostęp z obu stron musi być dokładnie skoordynowany. Rozwiązaniem tego problemu jest udostępnianie stanów dostępnych na poziomie atomów. W tym celu możemy wykorzystać Int32Array
, które jest obsługiwane przez SAB.
Mechanizm synchronizacji: SharedArrayBuffer i Atomics
Każde pole tablicy States zawiera ważne informacje o wspólnych buforach. Najważniejszym z nich jest pole synchronizacji (REQUEST_RENDER
). Pole to jest sprawdzane przez Workera, który przetwarza dźwięk po jego aktywacji. Wraz z SharedArrayBuffer (SAB) interfejs Atomics API umożliwia stosowanie tego mechanizmu.
Pamiętaj, że synchronizacja 2 wątków jest raczej luźna. Początek działania funkcji Worker.process()
zostanie wywołany przez metodę AudioWorkletProcessor.process()
, ale procesor AudioWorkletProcessor nie czeka, aż Worker.process()
zakończy działanie. Jest to celowe działanie. Proces AudioWorkletProcessor jest sterowany przez wywołanie zwrotne audio, więc nie może być blokowany synchronicznie. W najgorszym przypadku strumień audio może się powielać lub gubić, ale ostatecznie odzyska stabilność, gdy wydajność renderowania się ustabilizuje.
Konfiguracja i uruchomienie
Jak widać na diagramie powyżej, ta architektura ma kilka elementów do uporządkowania: DedicatedWorkerGlobalScope (DWGS), AudioWorkletGlobalScope (AWGS), SharedArrayBuffer i główny wątek. W następnych krokach opisano, co powinno się wydarzyć na etapie inicjalizacji.
Zdarzenie inicjujące
- [Main] Konstruktor AudioWorkletNode jest wywoływany.
- Utwórz instancję roboczą.
- Zostanie utworzony powiązany element AudioWorkletProcessor.
- [DWGS] Worker tworzy 2 SharedArrayBuffers. (jeden dla wspólnych stanów, a drugi dla danych audio)
- [DWGS] Worker wysyła odwołania do SharedArrayBuffer do AudioWorkletNode.
- [Main] AudioWorkletNode wysyła odwołania SharedArrayBuffer do AudioWorkletProcessor.
- [AWGS] AudioWorkletProcessor powiadamia AudioWorkletNode, że konfiguracja została zakończona.
Po zakończeniu inicjalizacji funkcja AudioWorkletProcessor.process()
zaczyna być wywoływana. Oto, co powinno się dziać w każdej iteracji pętli renderowania.
Pętla renderowania
- [AWGS]
AudioWorkletProcessor.process(inputs, outputs)
jest wywoływany dla każdego renderowania.- Plik
inputs
zostanie przesłany do wejścia SAB. - Wartość
outputs
zostanie wypełniona przez wykorzystanie danych audio w pliku SAB wyjściowym. - Aktualizuje States SAB, stosując nowe indeksy bufora.
- Jeśli Output SAB zbliża się do progu podnoszenia, zadanie Wake Worker będzie renderować więcej danych audio.
- Plik
- [DWGS] Worker czeka (w stanie uśpienia) na sygnał aktywacji z
AudioWorkletProcessor.process()
. Gdy urządzenie się obudzi:- Pobiera indeksy buforów z States SAB.
- Uruchom funkcję przetwarzania z danymi z pliku wejściowego SAB, aby wypełnić wyjściowy plik SAB.
- Aktualizuje stany SAB z odpowiednimi indeksami bufora.
- przechodzi w stan uśpienia i czeka na następny sygnał.
Przykładowy kod znajdziesz tutaj, ale pamiętaj, że aby to demo działało, musisz włączyć flagę eksperymentalną SharedArrayBuffer. Ze względu na prostotę kod został napisany w czystym języku JavaScript, ale w razie potrzeby można go zastąpić kodem WebAssembly. W takim przypadku należy zachować szczególną ostrożność i zarządzanie pamięcią należy opakować za pomocą klasy HeapAudioBuffer.
Podsumowanie
Ostatecznym celem modułu Audio Worklet jest zapewnienie prawdziwej „rozszerzalności” interfejsu Web Audio API. Projektowanie trwało kilka lat, aby umożliwić implementację reszty interfejsu Web Audio API za pomocą modułu Audio Worklet. W związku z tym projektowanie stało się bardziej skomplikowane, co może stanowić nieoczekiwane wyzwanie.
Na szczęście, taka złożoność jest podyktowana wyłącznie chęcią wzmocnienia pozycji deweloperów. Możliwość uruchamiania WebAssembly w ramach AudioWorkletGlobalScope otwiera ogromne możliwości wydajnego przetwarzania dźwięku w internecie. W przypadku dużych aplikacji audio napisanych w języku C lub C++ warto rozważyć użycie modułu Audio Worklet z użyciem SharedArrayBuffers i Workers.
Środki
Szczególne podziękowania dla Chrisa Wilsona, Jasona Millera, Joshuy Bella i Raymonda Toya za sprawdzenie wersji roboczej tego artykułu i przesłanie cennych opinii.