Wzorzec projektu Worklet audio

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 kilku interfejsach 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 opisany w tym poście.

  • BaseAudioContext główny obiekt interfejsu Web Audio API.
  • Audio Worklet: specjalny moduł wczytywania plików skryptu do operacji Audio Worklet. Należy do BaseAudioContext. BaseAudioContext może zawierać 1 element Audio Worklet. Załadowany plik skryptu jest oceniany w ramach zakresu AudioWorkletGlobalScope i używany do tworzenia instancji AudioWorkletProcessor.
  • AudioWorkletGlobalScope: specjalny globalny zakres JS dla operacji Worklet audio. Działa na dedykowanym 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. Obiekt BaseAudioContext może zawierać wiele węzłów AudioWorkletNodes, podobnie jak natywne węzły AudioNodes.
  • AudioWorkletProcessor: odpowiednik węzła AudioWorkletNode. Rzeczywista część 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 idealne uzupełnienie dla AudioWorkletProcessor. Połączenie tych dwóch funkcji zapewnia wiele korzyści w zakresie przetwarzania dźwięku w internecie, ale największe korzyści to: a) wprowadzenie do ekosystemu WebAudio dotychczasowego kodu przetwarzania dźwięku w języku C/C++, b) uniknięcie obciążenia związanego z kompilacją JIT w JS i zbieraniem elementów nieużywanych 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 przypadku 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 instancjonowania modułu:

  1. Tworzenie instancji modułu WebAssembly przez załadowanie kodu łączącego do AudioWorkletGlobalScope za pomocą audioContext.audioWorklet.addModule().
  2. 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.

Wzór instancjowania modułu WebAssembly: A. Użycie wywołania .addModule()
Wzorzec instancjowania modułu WebAssembly A: użycie funkcji .addModule()

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 kompilowanie w głównym wątku nie jest ogólnie zalecane, 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 tworzenia instancji modułu WASM B: korzystanie z przesyłania między wątkami w konstruktorze AudioWorkletNode
Wzór instancjowania modułu WASM B: korzystanie z przesyłania między wątkami w konstruktorze AudioWorkletNode

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ę.

Klasa HeapAudioBuffer ułatwiająca korzystanie z struktury stosu WASM
HeapAudioBuffer class for the easier usage of WASM heap

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).

Używanie RingBuffer w ramach metody „process()” klasy AudioWorkletProcessor
Zastosowanie pierścienia buforowego w metodzie „process()” klasy AudioWorkletProcessor

Algorytm na diagramie:

  1. AudioWorkletProcessor przesyła 128 ramek do bufora wejściowego z wejścia.
  2. Wykonaj te czynności tylko wtedy, gdy bufor wejściowy zawiera co najmniej 512 ramek.
    1. Pobierz 512 ramek z bufora wejściowego RingBuffer.
    2. Przetworzenie 512 ramek za pomocą podanej funkcji WASM.
    3. Prześlij 512 ramek do bufora wyjściowego RingBuffer.
  3. AudioWorkletProcessor pobiera 128 ramek z bufora wyjściowego, aby wypełnić Output.

Jak widać na diagramie, ramki wejściowe są zawsze gromadzone w pierścieniu buforowym wejścia, które obsługuje przepełnienie bufora przez zastąpienie 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. Przykładem funkcji, którą można zbudować na podstawie tego projektu, jest nagrywarka dźwięku.

Należy jednak pamiętać, że ta konstrukcja rozwiązuje tylko problem niezgodności rozmiaru bufora i nie wydłuża czasu wykonywania 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 struktury danych 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 klonowania 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, AtomicsWorker. 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.

Omówienie ostatniego wzorca projektowania: moduł audio, SharedArrayBuffer i Worker
Przegląd ostatniego wzorca projektowania: moduł audio Worklet, obiekt SharedArrayBuffer i worker

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

Gdy używasz współdzielonej 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
Mechanizm synchronizacji: SharedArrayBuffer i Atomics

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. Rozpoczęcie działania funkcji Worker.process() zostanie wywołane 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 wprowadzanie

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ępujących krokach opisano, co powinno się wydarzyć na etapie inicjalizacji.

Zdarzenie inicjujące
  1. [Main] Konstruktor AudioWorkletNode jest wywoływany.
    1. Utwórz instancję roboczą.
    2. Zostanie utworzony powiązany element AudioWorkletProcessor.
  2. [DWGS] Pracownik tworzy 2 obiekty SharedArrayBuffer. (jeden dla wspólnych stanów, a drugi dla danych audio)
  3. [DWGS] Worker wysyła odwołania do SharedArrayBuffer do AudioWorkletNode.
  4. [Main] AudioWorkletNode wysyła odwołania SharedArrayBuffer do AudioWorkletProcessor.
  5. [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
Renderowanie wielowątkowe z obiektem SharedArrayBuffers
Renderowanie wielowątkowe z obiektem SharedArrayBuffer
  1. [AWGS] AudioWorkletProcessor.process(inputs, outputs) jest wywoływany dla każdego renderowania.
    1. Plik inputs zostanie przesłany do wejścia SAB.
    2. Wartość outputs zostanie wypełniona przez wykorzystanie danych audio w pliku SAB wyjściowym.
    3. Aktualizuje States SAB, stosując nowe indeksy bufora.
    4. Jeśli Output SAB zbliża się do progu podnoszenia, zadanie Wake Worker będzie renderować więcej danych audio.
  2. [DWGS] Proces roboczy oczekuje (w stanie uśpienia) na sygnał aktywacji z AudioWorkletProcessor.process(). Gdy urządzenie się obudzi:
    1. Pobiera indeksy buforów z States SAB.
    2. Uruchom funkcję przetwarzania z danymi z pliku wejściowego SAB, aby wypełnić wyjściowy plik SAB.
    3. Aktualizuje stany SAB odpowiednimi indeksami bufora.
    4. 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ę pozostałej części 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 potrzebami deweloperów. Możliwość uruchamiania WebAssembly w ramach AudioWorkletGlobalScope stwarza ogromne możliwości przetwarzania dźwięku w internecie z wysoką wydajnością. W przypadku dużych aplikacji audio napisanych w języku C lub C++ warto rozważyć użycie elementu Worklet do obsługi dźwięku z użyciem obiektów SharedArrayBuffers i elementów roboczych.

Ś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.