오디오 Worklet 디자인 패턴

Hongchan Choi

오디오 워크렛에 관한 이전 도움말에서는 기본 개념과 사용법을 자세히 설명했습니다. Chrome 66에서 출시된 이후 실제 애플리케이션에서 이를 사용하는 방법에 관한 더 많은 예시를 제공해 달라는 요청이 많았습니다. Audio Worklet은 WebAudio의 잠재력을 최대한 발휘할 수 있지만, 여러 JS API로 래핑된 동시 프로그래밍을 이해해야 하므로 이를 활용하는 것은 쉽지 않을 수 있습니다. WebAudio에 익숙한 개발자라도 Audio Worklet을 다른 API (예: WebAssembly)와 통합하는 것은 어려울 수 있습니다.

이 도움말에서는 실제 설정에서 오디오 워크렛을 사용하는 방법을 자세히 설명하고 최대한 활용하는 데 도움이 되는 팁을 제공합니다. 코드 예시 및 라이브 데모도 확인해 보세요.

요약: 오디오 워크렛

시작하기 전에 이전에 이 게시물에서 소개된 오디오 워크렛 시스템에 관한 용어와 사실을 간단히 복습해 보겠습니다.

  • BaseAudioContext: Web Audio API의 기본 객체입니다.
  • 오디오 워크렛: 오디오 워크렛 작업을 위한 특수 스크립트 파일 로더입니다. BaseAudioContext에 속합니다. BaseAudioContext에는 오디오 워크렛이 하나 있을 수 있습니다. 로드된 스크립트 파일은 AudioWorkletGlobalScope에서 평가되며 AudioWorkletProcessor 인스턴스를 만드는 데 사용됩니다.
  • AudioWorkletGlobalScope: 오디오 워크렛 작업을 위한 특수 JS 전역 범위입니다. WebAudio 전용 렌더링 스레드에서 실행됩니다. BaseAudioContext에는 하나의 AudioWorkletGlobalScope가 있을 수 있습니다.
  • AudioWorkletNode: 오디오 워크렛 작업을 위해 설계된 AudioNode입니다. BaseAudioContext에서 인스턴스화됩니다. BaseAudioContext는 네이티브 AudioNodes와 마찬가지로 여러 AudioWorkletNodes를 보유할 수 있습니다.
  • AudioWorkletProcessor: AudioWorkletNode의 대응 항목입니다. 사용자 제공 코드로 오디오 스트림을 처리하는 AudioWorkletNode의 실제 핵심입니다. AudioWorkletNode가 생성될 때 AudioWorkletGlobalScope에서 인스턴스화됩니다. AudioWorkletNode에는 일치하는 AudioWorkletProcessor가 하나 있을 수 있습니다.

디자인 패턴

WebAssembly에서 오디오 워크렛 사용

WebAssembly는 AudioWorkletProcessor의 완벽한 컴패니언입니다. 이 두 가지 기능을 결합하면 웹에서 오디오를 처리할 때 다양한 이점이 있지만 가장 큰 두 가지 이점은 a) 기존 C/C++ 오디오 처리 코드를 WebAudio 생태계로 가져오고 b) 오디오 처리 코드에서 JS JIT 컴파일 및 가비지 컬렉션의 오버헤드를 방지하는 것입니다.

전자는 오디오 처리 코드 및 라이브러리에 기존 투자가 있는 개발자에게 중요하지만 후자는 API의 거의 모든 사용자에게 중요합니다. WebAudio의 경우 안정적인 오디오 스트림의 타이밍 예산이 매우 까다롭습니다. 샘플링 레이트가 44.1KHz인 경우 3ms에 불과합니다. 오디오 처리 코드에 약간의 문제가 있어도 글리치가 발생할 수 있습니다. 개발자는 처리 속도를 높이기 위해 코드를 최적화해야 하지만 생성되는 JS 가비지 양도 최소화해야 합니다. WebAssembly를 사용하면 두 문제를 동시에 해결할 수 있는 솔루션이 될 수 있습니다. 속도가 더 빠르고 코드에서 가비지를 생성하지 않습니다.

다음 섹션에서는 WebAssembly를 오디오 워크렛과 함께 사용하는 방법을 설명하며 함께 제공되는 코드 예시는 여기에서 확인할 수 있습니다. Emscripten 및 WebAssembly (특히 Emscripten 글루 코드)를 사용하는 방법에 관한 기본 튜토리얼은 이 도움말을 참고하세요.

설정

멋진 계획이지만 제대로 설정하려면 약간의 구조가 필요합니다. 가장 먼저 던져야 할 설계 질문은 WebAssembly 모듈을 인스턴스화하는 방법과 위치입니다. Emscripten의 글루 코드를 가져온 후 모듈 인스턴스화에는 두 가지 경로가 있습니다.

  1. audioContext.audioWorklet.addModule()를 통해 글루 코드를 AudioWorkletGlobalScope에 로드하여 WebAssembly 모듈을 인스턴스화합니다.
  2. 기본 범위에서 WebAssembly 모듈을 인스턴스화한 다음 AudioWorkletNode의 생성자 옵션을 통해 모듈을 전송합니다.

이 결정은 디자인과 선호도에 따라 크게 달라지지만, WebAssembly 모듈은 AudioWorkletGlobalScope에서 WebAssembly 인스턴스를 생성할 수 있으며, 이 인스턴스는 AudioWorkletProcessor 인스턴스 내에서 오디오 처리 커널이 됩니다.

WebAssembly 모듈 인스턴스화 패턴 A: .addModule() 호출 사용
WebAssembly 모듈 인스턴스화 패턴 A: .addModule() 호출 사용

패턴 A가 제대로 작동하려면 Emscripten에 구성에 맞는 올바른 WebAssembly 글루 코드를 생성하기 위한 몇 가지 옵션이 필요합니다.

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

이 옵션을 사용하면 AudioWorkletGlobalScope에서 WebAssembly 모듈을 동기식으로 컴파일할 수 있습니다. 또한 모듈이 초기화된 후 로드될 수 있도록 mycode.js에 AudioWorkletProcessor의 클래스 정의를 추가합니다. 동기식 컴파일을 사용하는 주된 이유는 audioWorklet.addModule()의 약속 해제가 AudioWorkletGlobalScope의 약속 해제를 기다리지 않기 때문입니다. 기본 스레드에서 동기식 로드 또는 컴파일을 실행하면 동일한 스레드의 다른 작업이 차단되므로 일반적으로 권장되지 않지만 여기서는 컴파일이 기본 스레드에서 실행되는 AudioWorkletGlobalScope에서 실행되므로 규칙을 우회할 수 있습니다. 자세한 내용은 여기를 참고하세요.

WASM 모듈 인스턴스화 패턴 B: AudioWorkletNode 생성자의 교차 스레드 전송 사용
WASM 모듈 인스턴스화 패턴 B: AudioWorkletNode 생성자의 교차 스레드 전송 사용

패턴 B는 비동기 대규모 작업이 필요한 경우에 유용할 수 있습니다. 서버에서 글루 코드를 가져오고 모듈을 컴파일하는 데 기본 스레드를 활용합니다. 그런 다음 AudioWorkletNode의 생성자를 통해 WASM 모듈을 전송합니다. 이 패턴은 AudioWorkletGlobalScope가 오디오 스트림 렌더링을 시작한 후에 모듈을 동적으로 로드해야 하는 경우에 더욱 적합합니다. 모듈 크기에 따라 렌더링 중간에 모듈을 컴파일하면 스트림에 글리치가 발생할 수 있습니다.

WASM 힙 및 오디오 데이터

WebAssembly 코드는 전용 WASM 힙 내에 할당된 메모리에서만 작동합니다. 이를 활용하려면 WASM 힙과 오디오 데이터 배열 간에 오디오 데이터를 앞뒤로 클론해야 합니다. 예시 코드의 HeapAudioBuffer 클래스는 이 작업을 잘 처리합니다.

WASM 힙을 더 쉽게 사용할 수 있는 HeapAudioBuffer 클래스
WASM 힙을 더 쉽게 사용할 수 있는 HeapAudioBuffer 클래스

WASM 힙을 오디오 워크렛 시스템에 직접 통합하기 위한 초기 제안이 논의 중입니다. JS 메모리와 WASM 힙 간의 중복 데이터 클론을 제거하는 것이 자연스러워 보이지만 구체적인 세부정보를 해결해야 합니다.

버퍼 크기 불일치 처리

AudioWorkletNode 및 AudioWorkletProcessor 쌍은 일반 AudioNode처럼 작동하도록 설계되었습니다. AudioWorkletNode는 다른 코드와의 상호작용을 처리하고 AudioWorkletProcessor는 내부 오디오 처리를 처리합니다. 일반 AudioNode는 한 번에 128개 프레임을 처리하므로 AudioWorkletProcessor도 핵심 기능이 되려면 동일하게 처리해야 합니다. 이는 내부 버퍼링으로 인한 추가 지연 시간이 AudioWorkletProcessor 내에 도입되지 않도록 하는 오디오 워크렛 설계의 장점 중 하나이지만 처리 함수에 128프레임과 다른 버퍼 크기가 필요한 경우 문제가 될 수 있습니다. 이러한 경우에 일반적인 해결 방법은 순환 버퍼 또는 FIFO라고도 하는 링 버퍼를 사용하는 것입니다.

다음은 내부에서 두 개의 링 버퍼를 사용하여 512프레임을 입력과 출력하는 WASM 함수를 수용하는 AudioWorkletProcessor의 다이어그램입니다. 여기서 512는 임의로 선택한 숫자입니다.

AudioWorkletProcessor의 `process()` 메서드 내에서 RingBuffer 사용
AudioWorkletProcessor의 `process()` 메서드 내부에서 RingBuffer 사용

다이어그램의 알고리즘은 다음과 같습니다.

  1. AudioWorkletProcessor는 입력에서 128프레임을 입력 RingBuffer에 푸시합니다.
  2. 입력 RingBuffer에 프레임이 512개 이상인 경우에만 다음 단계를 실행합니다.
    1. 입력 RingBuffer에서 512프레임을 가져옵니다.
    2. 주어진 WASM 함수로 512프레임을 처리합니다.
    3. 512프레임을 Output RingBuffer에 푸시합니다.
  3. AudioWorkletProcessor는 Output RingBuffer에서 128프레임을 가져와 Output을 채웁니다.

다이어그램에서 볼 수 있듯이 입력 프레임은 항상 입력 RingBuffer에 누적되며 버퍼에서 가장 오래된 프레임 블록을 덮어써서 버퍼 오버플로를 처리합니다. 이는 실시간 오디오 애플리케이션에서 적절한 작업입니다. 마찬가지로 출력 프레임 블록은 항상 시스템에서 가져옵니다. 출력 RingBuffer의 버퍼 언더플로 (데이터 부족)로 인해 무음이 발생하여 스트림에 글리치가 발생합니다.

이 패턴은 ScriptProcessorNode (SPN)를 AudioWorkletNode로 대체할 때 유용합니다. SPN을 사용하면 개발자가 256~16, 384프레임 사이의 버퍼 크기를 선택할 수 있으므로 SPN을 AudioWorkletNode로 드롭인 대체하는 것이 어려울 수 있으며 링 버퍼를 사용하면 좋은 해결 방법이 됩니다. 오디오 녹음기는 이 설계를 기반으로 빌드할 수 있는 좋은 예입니다.

그러나 이 설계는 버퍼 크기 불일치만 조정할 뿐 지정된 스크립트 코드를 실행하는 데 더 많은 시간을 제공하지 않는다는 점에 유의해야 합니다. 코드가 렌더링 퀀텀의 타이밍 예산 (44.1Khz에서 ~3ms) 내에 작업을 완료할 수 없는 경우 후속 콜백 함수의 시작 타이밍에 영향을 미치고 결국 글리치가 발생합니다.

이 설계를 WebAssembly와 혼합하는 것은 WASM 힙 주변의 메모리 관리로 인해 복잡할 수 있습니다. 작성 시점에서 WASM 힙에 들어가고 나오는 데이터는 클론해야 하지만 HeapAudioBuffer 클래스를 활용하면 메모리 관리를 약간 더 쉽게 할 수 있습니다. 중복 데이터 클론을 줄이기 위해 사용자 할당 메모리를 사용하는 아이디어는 향후 논의될 예정입니다.

RingBuffer 클래스는 여기에서 확인할 수 있습니다.

WebAudio 강력한 기능: 오디오 워크렛 및 SharedArrayBuffer

이 도움말의 마지막 디자인 패턴은 여러 최신 API(오디오 워크렛, SharedArrayBuffer, Atomics, Worker)를 한곳에 모으는 것입니다. 이 간단하지 않은 설정을 통해 C/C++로 작성된 기존 오디오 소프트웨어가 원활한 사용자 환경을 유지하면서 웹브라우저에서 실행될 수 있는 경로가 열립니다.

마지막 디자인 패턴인 오디오 워크렛, SharedArrayBuffer, Worker의 개요
마지막 디자인 패턴: 오디오 워크렛, SharedArrayBuffer, 작업자 개요

이 설계의 가장 큰 장점은 오디오 처리 전용으로 DedicatedWorkerGlobalScope를 사용할 수 있다는 것입니다. Chrome에서 WorkerGlobalScope는 WebAudio 렌더링 스레드보다 우선순위가 낮은 스레드에서 실행되지만 AudioWorkletGlobalScope에 비해 몇 가지 이점이 있습니다. DedicatedWorkerGlobalScope는 범위에서 사용할 수 있는 API 노출 영역 측면에서 덜 제한적입니다. 또한 Worker API가 몇 년 동안 존재했기 때문에 Emscripten에서 더 나은 지원을 기대할 수 있습니다.

SharedArrayBuffer는 이 디자인이 효율적으로 작동하는 데 중요한 역할을 합니다. Worker와 AudioWorkletProcessor 모두 비동기 메시지(MessagePort)를 갖추고 있지만 반복되는 메모리 할당과 메시지 지연 시간으로 인해 실시간 오디오 처리에는 최적화되지 않습니다. 따라서 빠른 양방향 데이터 전송을 위해 두 스레드에서 모두 액세스할 수 있는 메모리 블록을 미리 할당합니다.

Web Audio API 순수주의자의 관점에서 보면 이 설계는 Audio Worklet을 간단한 '오디오 싱크'로 사용하고 Worker에서 모든 작업을 실행하므로 최적화되지 않은 것처럼 보일 수 있습니다. 하지만 JavaScript로 C/C++ 프로젝트를 다시 작성하는 데 드는 비용이 prohibitive하거나 불가능할 수 있다는 점을 고려할 때 이 설계는 이러한 프로젝트에 가장 효율적인 구현 경로가 될 수 있습니다.

공유 상태 및 원자

오디오 데이터에 공유 메모리를 사용할 때는 양쪽의 액세스를 신중하게 조정해야 합니다. 원자적으로 액세스 가능한 상태를 공유하는 것이 이러한 문제의 해결 방법입니다. 이를 위해 SAB가 지원하는 Int32Array를 활용할 수 있습니다.

동기화 메커니즘: SharedArrayBuffer 및 Atomics
동기화 메커니즘: SharedArrayBuffer 및 Atomics

동기화 메커니즘: SharedArrayBuffer 및 Atomics

States 배열의 각 필드는 공유 버퍼에 관한 중요한 정보를 나타냅니다. 가장 중요한 것은 동기화 필드(REQUEST_RENDER)입니다. 작업자는 이 필드가 AudioWorkletProcessor에 의해 터치될 때까지 기다렸다가 깨어날 때 오디오를 처리합니다. SharedArrayBuffer (SAB)와 함께 Atomics API를 사용하면 이 메커니즘을 사용할 수 있습니다.

두 스레드의 동기화는 다소 느슨합니다. Worker.process()의 시작은 AudioWorkletProcessor.process() 메서드에 의해 트리거되지만 AudioWorkletProcessor는 Worker.process()가 완료될 때까지 기다리지 않습니다. 이는 의도된 동작입니다. AudioWorkletProcessor는 오디오 콜백에 의해 구동되므로 동기식으로 차단해서는 안 됩니다. 최악의 경우 오디오 스트림이 중복되거나 중단될 수 있지만 렌더링 성능이 안정화되면 결국 복구됩니다.

설정 및 실행

위 다이어그램과 같이 이 디자인에는 정렬할 여러 구성요소(DedicatedWorkerGlobalScope(DWGS), AudioWorkletGlobalScope(AWGS), SharedArrayBuffer, 기본 스레드)가 있습니다. 다음 단계에서는 초기화 단계에서 어떤 일이 일어나야 하는지 설명합니다.

초기화
  1. [기본] AudioWorkletNode 생성자가 호출됩니다.
    1. Worker를 만듭니다.
    2. 연결된 AudioWorkletProcessor가 생성됩니다.
  2. [DWGS] 작업자가 SharedArrayBuffer 2개를 만듭니다. 하나는 공유 상태용, 다른 하나는 오디오 데이터용입니다.
  3. [DWGS] 작업자가 AudioWorkletNode에 SharedArrayBuffer 참조를 전송합니다.
  4. [기본] AudioWorkletNode가 SharedArrayBuffer 참조를 AudioWorkletProcessor로 전송합니다.
  5. [AWGS] AudioWorkletProcessor가 AudioWorkletNode에 설정이 완료되었음을 알립니다.

초기화가 완료되면 AudioWorkletProcessor.process()가 호출되기 시작합니다. 렌더링 루프의 각 반복에서 실행되어야 하는 작업은 다음과 같습니다.

렌더링 루프
SharedArrayBuffers를 사용한 멀티스레드 렌더링
SharedArrayBuffer를 사용한 멀티스레드 렌더링
  1. [AWGS] AudioWorkletProcessor.process(inputs, outputs)가 모든 렌더링 퀀텀에 대해 호출됩니다.
    1. inputs입력 SAB로 푸시됩니다.
    2. outputsOutput SAB에서 오디오 데이터를 소비하여 채워집니다.
    3. 이에 따라 새 버퍼 색인을 사용하여 States SAB를 업데이트합니다.
    4. Output SAB가 언더플로 임곗값에 가까워지면 Wake Worker를 실행하여 더 많은 오디오 데이터를 렌더링합니다.
  2. [DWGS] 작업자가 AudioWorkletProcessor.process()의 웨이크 신호를 기다립니다 (절전 모드). 기기의 전원이 켜지면 다음과 같이 표시됩니다.
    1. States SAB에서 버퍼 색인을 가져옵니다.
    2. 입력 SAB의 데이터로 프로세스 함수를 실행하여 출력 SAB를 채웁니다.
    3. 이에 따라 버퍼 색인으로 States SAB를 업데이트합니다.
    4. 절전 모드로 전환되고 다음 신호를 기다립니다.

예시 코드는 여기에서 확인할 수 있지만 이 데모가 작동하려면 SharedArrayBuffer 실험 플래그를 사용 설정해야 합니다. 이 코드는 단순성을 위해 순수 JS 코드로 작성되었지만 필요한 경우 WebAssembly 코드로 대체할 수 있습니다. 이러한 경우는 메모리 관리를 HeapAudioBuffer 클래스로 래핑하여 각별히 주의해서 처리해야 합니다.

결론

오디오 워크레트의 궁극적인 목표는 Web Audio API를 진정으로 '확장 가능'하게 만드는 것입니다. 오디오 워크레트를 사용하여 나머지 Web Audio API를 구현할 수 있도록 설계하는 데 수년간의 노력이 투입되었습니다. 따라서 디자인의 복잡성이 높아졌으며 이는 예상치 못한 문제가 될 수 있습니다.

다행히 이러한 복잡성은 순전히 개발자를 지원하기 위한 것입니다. AudioWorkletGlobalScope에서 WebAssembly를 실행할 수 있으면 웹에서 고성능 오디오 처리를 위한 엄청난 잠재력을 활용할 수 있습니다. C 또는 C++로 작성된 대규모 오디오 애플리케이션의 경우 SharedArrayBuffers 및 Worker와 함께 Audio Worklet을 사용하는 것이 매력적인 옵션일 수 있습니다.

크레딧

이 도움말의 초안을 검토하고 유용한 의견을 제공해 주신 크리스 윌슨, 제이슨 밀러, 조슈아 벨, 레이먼드 토이님께 감사드립니다.