오디오 Worklet 디자인 패턴

Hongchan Choi

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

이 문서에서는 독자가 실제 환경에서 오디오 Worklet을 사용하는 방법을 더 잘 이해하고 최대한 활용하는 팁을 제공합니다. 코드 예제 및 실시간 데모도 확인해 보세요.

요약: 오디오 Worklet

자세히 알아보기 전에 이전에 이 게시물에서 소개한 오디오 Worklet 시스템에 관한 용어와 사실을 간단히 요약해 보겠습니다.

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

설계 패턴

WebAssembly와 함께 오디오 Worklet 사용

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

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

다음 섹션에서는 WebAssembly를 오디오 Worklet과 함께 사용하는 방법을 설명하고 함께 제공되는 코드 예는 여기에서 확인할 수 있습니다. 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 힙을 오디오 Worklet 시스템에 직접 통합하기 위한 초기 제안이 논의되고 있습니다. JS 메모리와 WASM 힙 간의 이 중복 데이터 클론을 제거하는 것은 자연스러워 보이지만 구체적인 세부 사항은 해결해야 합니다.

버퍼 크기 불일치 처리

AudioWorkletNode와 AudioWorkletProcessor의 쌍은 일반 AudioNode처럼 작동하도록 설계되었습니다. AudioWorkletNode는 다른 코드와의 상호작용을 처리하고 AudioWorkletProcessor는 내부 오디오 처리를 처리합니다. 일반 AudioNode는 한 번에 128프레임을 처리하므로 AudioWorkletProcessor는 동일한 작업을 실행해야 핵심 기능이 될 수 있습니다. 이는 AudioWorkletProcessor에 내부 버퍼링으로 인한 추가 지연 시간이 발생하지 않도록 하는 Audio Worklet 설계의 장점 중 하나이지만 처리 함수가 128프레임과 다른 버퍼 크기를 요구하는 경우 문제가 될 수 있습니다. 이러한 경우에 대한 일반적인 해결책은 원형 버퍼 또는 FIFO로도 알려진 링 버퍼를 사용하는 것입니다.

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

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

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

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

다이어그램에서 볼 수 있듯이 입력 프레임은 항상 Input RingBuffer에 누적되며 버퍼에서 가장 오래된 프레임 블록을 덮어쓰기하여 버퍼 오버플로를 처리합니다. 이는 실시간 오디오 애플리케이션에서는 합당한 조치입니다. 마찬가지로 출력 프레임 블록도 항상 시스템에 의해 풀링됩니다. Output RingBuffer의 버퍼 언더플로 (데이터 불충분)로 인해 무음이 발생하여 스트림에서 결함을 일으킵니다.

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

그러나 이 설계는 버퍼 사이즈 불일치만 조정할 뿐 지정된 스크립트 코드를 실행할 수 있는 시간은 늘어나지 않는다는 점을 이해하는 것이 중요합니다. 코드가 렌더링 퀀텀의 타이밍 예산 (44.1Khz에서 약 3ms) 내에서 작업을 완료할 수 없는 경우 후속 콜백 함수의 시작 타이밍에 영향을 주고 결과적으로 결함이 발생합니다.

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

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

WebAudio Powerhouse: 오디오 Worklet 및 SharedArrayBuffer

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

마지막 디자인 패턴 개요: Audio Worklet, SharedArrayBuffer, Worker
마지막 설계 패턴 개요: 오디오 Worklet, SharedArrayBuffer, 작업자

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

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

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

공유 상태 및 원자

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

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

동기화 메커니즘: SharedArrayBuffer 및 Atomics

상태 배열의 각 필드는 공유 버퍼에 관한 중요한 정보를 나타냅니다. 가장 중요한 것은 동기화 필드(REQUEST_RENDER)입니다. 작업자는 AudioWorkletProcessor에 이 필드가 연결될 때까지 기다렸다가 절전 모드가 해제되면 오디오를 처리합니다. Atomics API는 SharedArrayBuffer (SAB)와 함께 이를 가능하게 해 줍니다.

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

설정 및 실행

위의 다이어그램에서 볼 수 있듯이 이 디자인에는 DedicatedWorkerGlobalScope (DWGS), AudioWorkletGlobalScope (AWGS), SharedArrayBuffer 및 기본 스레드 등 정렬할 여러 구성요소가 있습니다. 다음 단계에서는 초기화 단계에서 수행해야 하는 작업을 설명합니다.

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

초기화가 완료되면 AudioWorkletProcessor.process()가 호출되기 시작합니다. 렌더링 루프가 반복될 때마다 다음과 같은 상황이 발생합니다.

렌더링 루프
SharedArrayBuffers를 사용한 다중 스레드 렌더링
SharedArrayBuffers를 사용한 다중 스레드 렌더링
  1. [AWGS] 모든 렌더링 퀀텀에 대해 AudioWorkletProcessor.process(inputs, outputs)가 호출됩니다.
    1. inputs입력 SAB로 푸시됩니다.
    2. outputs출력 SAB의 오디오 데이터를 사용하여 채워집니다.
    3. 이에 따라 새로운 버퍼 인덱스로 States SAB를 업데이트합니다.
    4. 출력 SAB가 언더플로 기준점에 가까워지면 Wake Worker가 더 많은 오디오 데이터를 렌더링합니다.
  2. [DWGS] 작업자가 AudioWorkletProcessor.process()의 절전 모드 해제 신호를 기다립니다 (절전 모드). 절전 모드가 해제된 후:
    1. States SAB에서 버퍼 색인을 가져옵니다.
    2. 입력 SAB의 데이터로 프로세스 함수를 실행하여 출력 SAB를 채웁니다.
    3. 버퍼 인덱스로 States SAB를 적절하게 업데이트합니다.
    4. 절전 모드로 전환되고 다음 신호를 기다립니다.

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

결론

오디오 Worklet의 궁극적인 목표는 Web Audio API를 진정한 '확장 가능'하게 만드는 것입니다. 오디오 Worklet을 사용하여 나머지 웹 오디오 API를 구현할 수 있도록 다년간의 노력을 기울였습니다. 따라서 설계가 더 복잡해졌으며 이는 예상치 못한 문제가 될 수 있습니다.

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

크레딧

이 문서의 초안을 검토하고 유용한 의견을 제공해 주신 Chris Wilson, Jason Miller, Joshua Bell, Raymond Toy에게 특별히 감사드립니다.