WebAssembly/Rust 튜토리얼: 완벽한 피치 오디오 처리
게시 됨: 2022-03-11모든 최신 브라우저에서 지원되는 WebAssembly(또는 "Wasm")는 웹용 사용자 경험을 개발하는 방식을 변화시키고 있습니다. 다른 프로그래밍 언어로 작성된 라이브러리 또는 전체 프로그램을 웹 브라우저에서 실행할 수 있는 간단한 바이너리 실행 형식입니다.
개발자는 종종 다음과 같이 생산성을 높일 수 있는 방법을 찾습니다.
- 여러 대상 플랫폼에 대해 단일 앱 코드베이스를 사용하지만 모든 대상 플랫폼에서 앱이 잘 실행되도록 함
- 데스크탑 과 모바일 환경에서 매끄럽고 아름다운 UX 만들기
- 오픈 소스 라이브러리 에코시스템을 활용하여 앱 개발 중 "바퀴를 재발명" 방지
프론트 엔드 개발자의 경우 WebAssembly는 세 가지 모두를 제공하여 기본 모바일 또는 데스크톱 경험에 필적하는 웹 앱 UI에 대한 검색에 답합니다. C++ 또는 Go!와 같은 자바스크립트가 아닌 언어로 작성된 라이브러리도 사용할 수 있습니다.
이 Wasm/Rust 튜토리얼에서는 기타 튜너와 같은 간단한 음높이 감지기 앱을 만들 것입니다. 브라우저의 내장 오디오 기능을 사용하고 모바일 장치에서도 초당 60프레임(FPS)으로 실행됩니다. 이 튜토리얼을 따라하기 위해 Web Audio API를 이해하거나 Rust에 익숙할 필요도 없습니다. 그러나 JavaScript에 대한 편안함이 예상됩니다.
참고: 불행히도 이 글을 쓰는 시점에서 이 기사에서 사용된 기술(Web Audio API에만 해당)은 아직 Firefox에서 작동하지 않습니다. 따라서 Firefox의 뛰어난 Wasm 및 Web Audio API 지원에도 불구하고 당분간 이 튜토리얼에서는 Chrome, Chromium 또는 Edge를 권장합니다.
이 WebAssembly/Rust 튜토리얼에서 다루는 내용
- Rust에서 간단한 함수를 만들고 JavaScript에서 호출하기(WebAssembly를 통해)
- 브라우저에서 고성능 오디오 처리를 위해 브라우저의 최신
AudioWorklet
API 사용 - JavaScript에서 작업자 간 통신
- 모든 것을 베어본 React 애플리케이션으로 묶기
참고: 이 기사의 "이유"보다 "방법"에 더 관심이 있다면 언제든지 튜토리얼로 바로 이동하십시오.
왜 와즘?
WebAssembly를 사용하는 것이 합리적일 수 있는 몇 가지 이유가 있습니다.
- 상상할 수 있는 모든 언어 로 작성된 브라우저 내에서 코드를 실행할 수 있습니다.
- 여기에는 JavaScript 이외의 언어로 작성된 기존 라이브러리(숫자, 오디오 처리, 기계 학습 등)를 사용 하는 것이 포함됩니다.
- 사용된 언어 선택에 따라 Wasm은 기본에 가까운 속도로 작동할 수 있습니다. 이것은 웹 애플리케이션 성능 특성 을 모바일 및 데스크탑 모두에 대한 기본 경험에 훨씬 더 가깝게 가져올 가능성이 있습니다.
왜 항상 Wasm을 사용하지 않습니까?
WebAssembly의 인기는 확실히 계속 성장할 것입니다. 그러나 모든 웹 개발에는 적합하지 않습니다.
- 간단한 프로젝트의 경우 JavaScript, HTML 및 CSS를 고수하면 더 짧은 시간에 작동하는 제품을 제공할 수 있습니다.
- Internet Explorer와 같은 이전 브라우저는 Wasm을 직접 지원하지 않습니다.
- WebAssembly를 일반적으로 사용하려면 언어 컴파일러와 같은 도구를 도구 체인에 추가해야 합니다. 팀이 개발 및 지속적인 통합 도구를 가능한 한 단순하게 유지하는 것을 우선시한다면 Wasm을 사용하는 것은 이와 반대되는 실행이 될 것입니다.
왜 Wasm/Rust 튜토리얼이 필요한가요?
많은 프로그래밍 언어가 Wasm으로 컴파일되지만 이 예제에서는 Rust를 선택했습니다. Rust는 2010년 Mozilla에 의해 만들어졌으며 인기가 높아지고 있습니다. Rust는 Stack Overflow의 2020년 개발자 설문조사에서 "가장 사랑받는 언어"에서 1위를 차지했습니다. 그러나 WebAssembly와 함께 Rust를 사용하는 이유는 단순한 유행을 넘어서는 것입니다.
- 무엇보다도 Rust는 런타임이 짧습니다. 즉, 사용자가 사이트에 액세스할 때 브라우저에 전송되는 코드가 적어 웹사이트 공간을 적게 유지하는 데 도움이 됩니다.
- Rust는 JavaScript와의 높은 수준의 상호 운용성 을 지원하는 뛰어난 Wasm 지원을 제공합니다.
- Rust는 C/C++ 수준에 가까운 성능 을 제공하지만 매우 안전한 메모리 모델 을 가지고 있습니다. 다른 언어와 비교할 때 Rust는 코드를 컴파일하는 동안 추가 안전 검사를 수행하여 비어 있거나 초기화되지 않은 변수로 인한 충돌 가능성을 크게 줄입니다. 이로 인해 예기치 않은 문제가 발생할 때 오류 처리가 더 간단해지고 좋은 UX를 유지할 가능성이 높아집니다.
- Rust는 가비지 수집되지 않습니다 . 이는 Rust 코드가 메모리 할당 및 정리 시기를 완전히 제어하여 실시간 시스템의 핵심 요구 사항인 일관된 성능을 허용한다는 것을 의미합니다.
Rust의 많은 이점은 또한 가파른 학습 곡선과 함께 제공되므로 올바른 프로그래밍 언어를 선택하는 것은 코드를 개발하고 유지 관리할 팀 구성과 같은 다양한 요인에 따라 달라집니다.
WebAssembly 성능: 매끄럽고 매끄러운 웹 앱 유지
Rust를 사용하여 WebAssembly에서 프로그래밍하고 있으므로 처음에 Wasm으로 이끈 성능상의 이점을 얻기 위해 Rust를 어떻게 사용할 수 있을까요? GUI가 빠르게 업데이트되는 애플리케이션이 사용자에게 "부드러움"을 느끼려면 화면 하드웨어처럼 정기적으로 디스플레이를 새로 고칠 수 있어야 합니다. 이것은 일반적으로 60FPS이므로 애플리케이션은 ~16.7ms(1,000ms/60FPS) 이내에 사용자 인터페이스를 다시 그릴 수 있어야 합니다.
우리의 응용 프로그램은 실시간으로 현재 피치를 감지하고 표시합니다. 즉, 결합된 감지 계산과 그리기가 프레임당 16.7ms 이내로 유지되어야 합니다. 다음 섹션에서는 기본 스레드가 작업을 수행 하는 동안 다른 스레드에서 오디오를 분석하기 위해 브라우저 지원을 활용합니다. 계산과 그리기가 각각 16.7ms를 처리할 수 있기 때문에 이것은 성능 면에서 큰 승리입니다.
웹 오디오 기초
이 애플리케이션에서는 고성능 WebAssembly 오디오 모듈을 사용하여 피치 감지를 수행합니다. 또한 메인 스레드에서 계산이 실행되지 않도록 할 것입니다.
왜 우리는 일을 단순하게 유지하고 메인 스레드에서 피치 감지를 수행할 수 없나요?
- 오디오 처리는 종종 계산 집약적입니다. 이것은 매초 처리해야 하는 많은 수의 샘플 때문입니다. 예를 들어 오디오 피치를 안정적으로 감지하려면 초당 44,100개 샘플의 스펙트럼을 분석해야 합니다.
- JavaScript의 JIT 컴파일 및 가비지 수집은 메인 스레드에서 발생하며 일관된 성능을 위해 오디오 처리 코드에서 이를 방지하고 싶습니다.
- 오디오 프레임을 처리하는 데 걸리는 시간이 16.7ms 프레임 예산을 크게 차지한다면 UX는 애니메이션이 끊기는 문제를 겪을 것입니다.
- 성능이 낮은 모바일 장치에서도 앱이 원활하게 실행되기를 바랍니다!
웹 오디오 워크렛을 사용하면 오디오 처리가 메인 스레드를 유지할 수 없기 때문에 앱이 계속 부드러운 60FPS를 달성할 수 있습니다. 오디오 처리가 너무 느리고 뒤쳐지면 오디오 지연과 같은 다른 효과가 있습니다. 그러나 UX는 사용자에게 계속 반응합니다.
WebAssembly/Rust 튜토리얼: 시작하기
이 튜토리얼에서는 Node.js와 npx
가 설치되어 있다고 가정합니다. npx
가 아직 없는 경우 npm
(Node.js와 함께 제공됨)을 사용하여 설치할 수 있습니다.
npm install -g npx
웹 앱 만들기
이 Wasm/Rust 튜토리얼에서는 React를 사용할 것입니다.
터미널에서 다음 명령을 실행합니다.
npx create-react-app wasm-audio-app cd wasm-audio-app
이것은 npx를 사용하여 (Facebook에서 유지 관리하는 해당 패키지에 포함된) create-react-app
명령을 실행하여 npx
wasm-audio-app
디렉토리에 새로운 React 애플리케이션을 생성합니다.
create-react-app
은 React 기반의 단일 페이지 애플리케이션(SPA)을 생성하기 위한 CLI입니다. React로 새 프로젝트를 시작하는 것이 엄청나게 쉽습니다. 그러나 출력 프로젝트에는 교체해야 하는 상용구 코드가 포함되어 있습니다.
첫째, 개발 전반에 걸쳐 애플리케이션을 단위 테스트하는 것이 좋지만 테스트는 이 튜토리얼의 범위를 벗어납니다. 따라서 src/App.test.js
및 src/setupTests.js
setupTests.js 를 삭제하겠습니다.
애플리케이션 개요
애플리케이션에는 다섯 가지 주요 JavaScript 구성 요소가 있습니다.
-
public/wasm-audio/wasm-audio.js
에는 피치 감지 알고리즘을 제공하는 Wasm 모듈에 대한 JavaScript 바인딩이 포함되어 있습니다. -
public/PitchProcessor.js
는 오디오 처리가 일어나는 곳입니다. 웹 오디오 렌더링 스레드에서 실행되며 Wasm API를 사용합니다. -
src/PitchNode.js
는 웹 오디오 그래프에 연결되고 메인 스레드에서 실행되는 웹 오디오 노드의 구현을 포함합니다. -
src/setupAudio.js
는 웹 브라우저 API를 사용하여 사용 가능한 오디오 녹음 장치에 액세스합니다. -
src/App.js
및src/App.css
는 애플리케이션 사용자 인터페이스를 구성합니다.
애플리케이션의 핵심을 직접 탐구하고 Wasm 모듈에 대한 Rust 코드를 정의합시다. 그런 다음 웹 오디오 관련 JavaScript의 다양한 부분을 코딩하고 UI로 끝낼 것입니다.
1. Rust와 WebAssembly를 사용한 피치 감지
Rust 코드는 오디오 샘플 배열에서 음높이를 계산합니다.
녹을 얻다
다음 지침에 따라 개발용 Rust 체인을 구축할 수 있습니다.
Rust에서 WebAssembly 구성 요소를 빌드하기 위한 도구 설치
wasm-pack
을 사용하면 Rust에서 생성한 WebAssembly 구성 요소를 빌드, 테스트 및 게시할 수 있습니다. 아직 설치하지 않았다면 wasm-pack을 설치하십시오.
cargo-generate
은 기존 Git 리포지토리를 템플릿으로 활용하여 새로운 Rust 프로젝트를 시작하고 실행하는 데 도움이 됩니다. 이것을 사용하여 브라우저에서 WebAssembly를 사용하여 액세스할 수 있는 Rust의 간단한 오디오 분석기를 부트스트랩합니다.
Rust 체인과 함께 제공되는 cargo
도구를 사용하여 화물 cargo-generate
을 설치할 수 있습니다.
cargo install cargo-generate
설치(몇 분이 소요될 수 있음)가 완료되면 Rust 프로젝트를 만들 준비가 된 것입니다.
WebAssembly 모듈 만들기
앱의 루트 폴더에서 프로젝트 템플릿을 복제합니다.
$ cargo generate --git https://github.com/rustwasm/wasm-pack-template
새 프로젝트 이름을 묻는 메시지가 표시되면 wasm-audio
를 입력합니다.
wasm-audio
디렉토리에는 이제 다음 내용이 포함된 Cargo.toml
파일이 있습니다.
[package] name = "wasm-audio" version = "0.1.0" authors = ["Your Name <[email protected]"] edition = "2018" [lib] crate-type = ["cdylib", "rlib"] [features] default = ["console_error_panic_hook"] [dependencies] wasm-bindgen = "0.2.63" ...
Cargo.toml
은 Rust 패키지(Rust가 "crate"라고 부름)를 정의하는 데 사용되며, package.json
이 JavaScript 애플리케이션에 대해 수행하는 유사한 기능을 Rust 앱에 제공합니다.
[package]
섹션은 Rust의 공식 패키지 레지스트리에 패키지를 게시할 때 사용되는 메타데이터를 정의합니다.
[lib]
섹션은 Rust 컴파일 프로세스의 출력 형식을 설명합니다. 여기에서 "cdylib"는 Rust에게 다른 언어(이 경우 JavaScript)에서 로드할 수 있는 "동적 시스템 라이브러리"를 생성하도록 지시하고 "rlib"를 포함하면 생성된 라이브러리에 대한 메타데이터가 포함된 정적 라이브러리를 추가하도록 Rust에 지시합니다. 이 두 번째 지정자는 우리의 목적에 필요하지 않습니다. 이 크레이트를 종속성으로 사용하는 추가 Rust 모듈의 개발을 지원하지만 그대로 두어도 안전합니다.
[features]
에서 Rust의 처리되지 않은 오류 메커니즘( panic
이라고 함)을 디버깅을 위한 개발 도구에 표시되는 콘솔 오류로 변환하는 기능을 제공하기 위해 선택적 기능인 console_error_panic_hook
을 포함하도록 Rust에 요청합니다.
마지막으로 [dependencies]
는 이것이 의존하는 모든 크레이트를 나열합니다. 기본적으로 제공되는 유일한 종속성은 wasm-bindgen
이며, 이는 Wasm 모듈에 대한 JavaScript 바인딩의 자동 생성을 제공합니다.
Rust에서 피치 감지기 구현
이 앱의 목적은 실시간으로 음악가의 목소리나 악기의 음높이를 감지할 수 있도록 하는 것입니다. 이것이 가능한 한 빨리 실행되도록 하기 위해 WebAssembly 모듈은 피치를 계산하는 작업을 수행합니다. 단일 음성 음높이 감지의 경우 기존 Rust pitch-detection
라이브러리에 구현된 "McLeod" 음높이 방법을 사용합니다.
Node.js 패키지 관리자(npm)와 마찬가지로 Rust에는 Cargo라는 자체 패키지 관리자가 포함되어 있습니다. 이를 통해 Rust 크레이트 레지스트리에 게시된 패키지를 쉽게 설치할 수 있습니다.
종속성을 추가하려면 Cargo.toml
을 편집하고 종속성 섹션에 pitch-detection
행을 추가합니다.
[dependencies] wasm-bindgen = "0.2.63" pitch-detection = "0.1"
이것은 Cargo가 다음 cargo build
중에 pitch-detection
종속성을 다운로드하고 설치하도록 지시하거나 WebAssembly를 대상으로 하기 때문에 다음 wasm-pack
에서 수행됩니다.
Rust에서 JavaScript 호출 가능 피치 감지기 만들기
먼저 나중에 논의할 목적의 유용한 유틸리티를 정의하는 파일을 추가합니다.
wasm-audio/src/utils.rs
를 만들고 이 파일의 내용을 여기에 붙여넣습니다.
wasm-audio/lib.rs
에서 생성된 코드를 FFT(고속 푸리에 변환) 알고리즘을 통해 피치 감지를 수행하는 다음 코드로 교체합니다.
use pitch_detection::{McLeodDetector, PitchDetector}; use wasm_bindgen::prelude::*; mod utils; #[wasm_bindgen] pub struct WasmPitchDetector { sample_rate: usize, fft_size: usize, detector: McLeodDetector<f32>, } #[wasm_bindgen] impl WasmPitchDetector { pub fn new(sample_rate: usize, fft_size: usize) -> WasmPitchDetector { utils::set_panic_hook(); let fft_pad = fft_size / 2; WasmPitchDetector { sample_rate, fft_size, detector: McLeodDetector::<f32>::new(fft_size, fft_pad), } } pub fn detect_pitch(&mut self, audio_samples: Vec<f32>) -> f32 { if audio_samples.len() < self.fft_size { panic!("Insufficient samples passed to detect_pitch(). Expected an array containing {} elements but got {}", self.fft_size, audio_samples.len()); } // Include only notes that exceed a power threshold which relates to the // amplitude of frequencies in the signal. Use the suggested default // value of 5.0 from the library. const POWER_THRESHOLD: f32 = 5.0; // The clarity measure describes how coherent the sound of a note is. For // example, the background sound in a crowded room would typically be would // have low clarity and a ringing tuning fork would have high clarity. // This threshold is used to accept detect notes that are clear enough // (valid values are in the range 0-1). const CLARITY_THRESHOLD: f32 = 0.6; let optional_pitch = self.detector.get_pitch( &audio_samples, self.sample_rate, POWER_THRESHOLD, CLARITY_THRESHOLD, ); match optional_pitch { Some(pitch) => pitch.frequency, None => 0.0, } } }
이를 더 자세히 살펴보겠습니다.
#[wasm_bindgen]
wasm_bindgen
은 JavaScript와 Rust 간의 바인딩을 구현하는 데 도움이 되는 Rust 매크로입니다. WebAssembly로 컴파일할 때 이 매크로는 컴파일러에 클래스에 대한 JavaScript 바인딩을 생성하도록 지시합니다. 위의 Rust 코드는 단순히 Wasm 모듈에 대한 호출을 위한 얇은 래퍼인 JavaScript 바인딩으로 변환됩니다. JavaScript 간의 직접 공유 메모리와 결합된 가벼운 추상화 계층은 Wasm이 뛰어난 성능을 제공하는 데 도움이 됩니다.
#[wasm_bindgen] pub struct WasmPitchDetector { sample_rate: usize, fft_size: usize, detector: McLeodDetector<f32>, } #[wasm_bindgen] impl WasmPitchDetector { ... }
Rust에는 클래스 개념이 없습니다. 오히려, 객체의 데이터는 impl
또는 trait
를 통해 struct
와 동작으로 설명됩니다.
일반 기능이 아닌 객체를 통해 피치 감지 기능을 노출하는 이유는 무엇입니까? 그런 식으로 우리는 WasmPitchDetector
를 생성하는 동안 내부 McLeodDetector에서 사용하는 데이터 구조를 한 번만 초기화합니다. 이것은 작동 중 값비싼 메모리 할당을 피함으로써 detect_pitch
기능을 빠르게 유지합니다.
pub fn new(sample_rate: usize, fft_size: usize) -> WasmPitchDetector { utils::set_panic_hook(); let fft_pad = fft_size / 2; WasmPitchDetector { sample_rate, fft_size, detector: McLeodDetector::<f32>::new(fft_size, fft_pad), } }
Rust 애플리케이션이 쉽게 복구할 수 없는 오류를 만나면 panic!
매크로. 이것은 Rust가 오류를 보고하고 즉시 응용 프로그램을 종료하도록 지시합니다. 패닉을 사용하는 것은 잘못된 가정을 빠르게 포착할 수 있게 해주기 때문에 오류 처리 전략이 마련되기 전의 초기 개발에 특히 유용할 수 있습니다.
설정 중에 utils::set_panic_hook()
을 한 번 호출하면 브라우저 개발 도구에 패닉 메시지가 표시됩니다.
다음으로, 각 분석 FFT에 적용되는 제로 패딩의 양인 fft_pad
를 정의합니다. 알고리즘에서 사용하는 윈도우 기능과 함께 패딩을 사용하면 수신되는 샘플링된 오디오 데이터에서 분석이 이동할 때 결과를 "매끄럽게" 하는 데 도움이 됩니다. FFT 길이의 절반인 패드를 사용하면 많은 악기에서 잘 작동합니다.
마지막으로, Rust는 마지막 문의 결과를 자동으로 반환하므로 WasmPitchDetector
구조체 문은 new()
의 반환 값입니다.
나머지 impl WasmPitchDetector
Rust 코드는 피치 감지를 위한 API를 정의합니다.
pub fn detect_pitch(&mut self, audio_samples: Vec<f32>) -> f32 { ... }
이것은 Rust에서 멤버 함수 정의의 모습입니다. 공개 멤버 detect_pitch
가 WasmPitchDetector
에 추가됩니다. 첫 번째 인수는 struct
및 impl
필드를 포함하는 동일한 유형의 인스턴스화된 개체에 대한 변경 가능한 참조( &mut
)입니다. 그러나 이것은 아래에서 볼 수 있듯이 호출할 때 자동으로 전달됩니다.
또한 멤버 함수는 32비트 부동 소수점 숫자의 임의 크기 배열을 사용하여 단일 숫자를 반환합니다. 여기에서는 해당 샘플에서 계산된 결과 피치(Hz 단위)가 됩니다.
if audio_samples.len() < self.fft_size { panic!("Insufficient samples passed to detect_pitch(). Expected an array containing {} elements but got {}", self.fft_size, audio_samples.len()); }
위의 코드는 유효한 피치 분석이 수행될 수 있도록 함수에 충분한 샘플이 제공되었는지 여부를 감지합니다. 그렇지 않다면 Rust panic!
매크로가 호출되어 Wasm에서 즉시 종료되고 오류 메시지가 브라우저 dev-tools 콘솔에 인쇄됩니다.
let optional_pitch = self.detector.get_pitch( &audio_samples, self.sample_rate, POWER_THRESHOLD, CLARITY_THRESHOLD, );
이것은 최신 오디오 샘플에서 피치를 계산하기 위해 타사 라이브러리를 호출합니다. POWER_THRESHOLD
및 CLARITY_THRESHOLD
를 조정하여 알고리즘의 감도를 조정할 수 있습니다.
다른 언어의 switch
문과 유사하게 작동하는 match
키워드를 통해 부동 소수점 값의 묵시적 반환으로 끝납니다. Some()
및 None
을 사용하면 널 포인터 예외가 발생하지 않고 케이스를 적절하게 처리할 수 있습니다.
WebAssembly 애플리케이션 빌드
Rust 애플리케이션을 개발할 때 일반적인 빌드 절차는 cargo build
를 사용하여 빌드를 호출하는 것입니다. 그러나 우리는 Wasm 모듈을 생성하고 있으므로 Wasm을 대상으로 할 때 더 간단한 구문을 제공하는 wasm-pack
을 사용할 것입니다. (또한 결과 JavaScript 바인딩을 npm 레지스트리에 게시할 수 있지만 이는 이 자습서의 범위를 벗어납니다.)
wasm-pack
은 다양한 빌드 타겟을 지원합니다. Web Audio worklet에서 직접 모듈을 사용하므로 web
옵션을 대상으로 합니다. 다른 대상에는 webpack과 같은 번들러용 빌드 또는 Node.js에서 사용하기 위한 빌드가 포함됩니다. 우리는 이것을 wasm-audio/
하위 디렉토리에서 실행할 것입니다:
wasm-pack build --target web
성공하면 ./pkg
아래에 npm 모듈이 생성됩니다.
이것은 자체적으로 자동 생성된 package.json
이 있는 JavaScript 모듈입니다. 원하는 경우 npm 레지스트리에 게시할 수 있습니다. 지금은 간단하게 유지하기 위해 이 패키지를 public/ pkg
public/wasm-audio
폴더 아래에 복사하여 붙여넣으면 됩니다.
cp -R ./wasm-audio/pkg ./public/wasm-audio
이를 통해 웹 앱, 보다 구체적으로 PitchProcessor
에서 사용할 수 있는 Rust Wasm 모듈을 만들었습니다.
2. PitchProcessor
클래스(네이티브 AudioWorkletProcessor
기반)
이 응용 프로그램의 경우 최근에 광범위한 브라우저 호환성을 확보한 오디오 처리 표준을 사용합니다. 특히 Web Audio API를 사용하고 맞춤형 AudioWorkletProcessor
에서 값비싼 계산을 실행할 것입니다. 그런 다음 해당 사용자 지정 AudioWorkletNode
클래스( PitchNode
라고 함)를 기본 스레드로 다시 연결하는 다리로 만듭니다.
새 파일 public/PitchProcessor.js
를 만들고 다음 코드를 붙여넣습니다.
import init, { WasmPitchDetector } from "./wasm-audio/wasm_audio.js"; class PitchProcessor extends AudioWorkletProcessor { constructor() { super(); // Initialized to an array holding a buffer of samples for analysis later - // once we know how many samples need to be stored. Meanwhile, an empty // array is used, so that early calls to process() with empty channels // do not break initialization. this.samples = []; this.totalSamples = 0; // Listen to events from the PitchNode running on the main thread. this.port.onmessage = (event) => this.onmessage(event.data); this.detector = null; } onmessage(event) { if (event.type === "send-wasm-module") { // PitchNode has sent us a message containing the Wasm library to load into // our context as well as information about the audio device used for // recording. init(WebAssembly.compile(event.wasmBytes)).then(() => { this.port.postMessage({ type: 'wasm-module-loaded' }); }); } else if (event.type === 'init-detector') { const { sampleRate, numAudioSamplesPerAnalysis } = event; // Store this because we use it later to detect when we have enough recorded // audio samples for our first analysis. this.numAudioSamplesPerAnalysis = numAudioSamplesPerAnalysis; this.detector = WasmPitchDetector.new(sampleRate, numAudioSamplesPerAnalysis); // Holds a buffer of audio sample values that we'll send to the Wasm module // for analysis at regular intervals. this.samples = new Array(numAudioSamplesPerAnalysis).fill(0); this.totalSamples = 0; } }; process(inputs, outputs) { // inputs contains incoming audio samples for further processing. outputs // contains the audio samples resulting from any processing performed by us. // Here, we are performing analysis only to detect pitches so do not modify // outputs. // inputs holds one or more "channels" of samples. For example, a microphone // that records "in stereo" would provide two channels. For this simple app, // we use assume either "mono" input or the "left" channel if microphone is // stereo. const inputChannels = inputs[0]; // inputSamples holds an array of new samples to process. const inputSamples = inputChannels[0]; // In the AudioWorklet spec, process() is called whenever exactly 128 new // audio samples have arrived. We simplify the logic for filling up the // buffer by making an assumption that the analysis size is 128 samples or // larger and is a power of 2. if (this.totalSamples < this.numAudioSamplesPerAnalysis) { for (const sampleValue of inputSamples) { this.samples[this.totalSamples++] = sampleValue; } } else { // Buffer is already full. We do not want the buffer to grow continually, // so instead will "cycle" the samples through it so that it always // holds the latest ordered samples of length equal to // numAudioSamplesPerAnalysis. // Shift the existing samples left by the length of new samples (128). const numNewSamples = inputSamples.length; const numExistingSamples = this.samples.length - numNewSamples; for (let i = 0; i < numExistingSamples; i++) { this.samples[i] = this.samples[i + numNewSamples]; } // Add the new samples onto the end, into the 128-wide slot vacated by // the previous copy. for (let i = 0; i < numNewSamples; i++) { this.samples[numExistingSamples + i] = inputSamples[i]; } this.totalSamples += inputSamples.length; } // Once our buffer has enough samples, pass them to the Wasm pitch detector. if (this.totalSamples >= this.numAudioSamplesPerAnalysis && this.detector) { const result = this.detector.detect_pitch(this.samples); if (result !== 0) { this.port.postMessage({ type: "pitch", pitch: result }); } } // Returning true tells the Audio system to keep going. return true; } } registerProcessor("PitchProcessor", PitchProcessor);
PitchProcessor
는 PitchNode
의 컴패니언이지만 별도의 스레드에서 실행되므로 메인 스레드에서 수행되는 작업을 차단하지 않고 오디오 처리 계산을 수행할 수 있습니다.

주로 PitchProcessor
:
- Wasm 모듈을 컴파일하고
PitchNode
에 로드하여 PitchNode에서 보낸"send-wasm-module"
이벤트를 처리합니다. 완료되면"wasm-module-loaded"
이벤트를 전송하여PitchNode
에 알립니다. 이 콜백 접근 방식은PitchNode
와PitchProcessor
간의 모든 통신이 스레드 경계를 넘어 동기식으로 수행될 수 없기 때문에 필요합니다. - 또한
PitchNode
를 구성하여WasmPitchDetector
의"init-detector"
이벤트에 응답합니다. - 브라우저 오디오 그래프에서 수신된 오디오 샘플을 처리하고, 피치 감지 계산을 Wasm 모듈에 위임한 다음, 감지된 피치를 다시
PitchNode
로 보냅니다(onPitchDetectedCallback
을 통해 반응 레이어에 피치를 보냅니다). - 특정하고 고유한 이름으로 자신을 등록합니다. 이런 식으로 브라우저는 기본
AudioWorkletNode
인PitchNode
의 기본 클래스를 통해 나중에PitchNode
가 구성될 때PitchProcessor
를 인스턴스화하는 방법을 알 수 있습니다.setupAudio.js
를 참조하십시오.
다음 다이어그램은 PitchNode
와 PitchProcessor
간의 이벤트 흐름을 시각화합니다.
3. 웹 오디오 워크렛 코드 추가
PitchNode.js
는 맞춤형 피치 감지 오디오 처리에 대한 인터페이스를 제공합니다. PitchNode
개체는 AudioWorklet
스레드에서 작동하는 WebAssembly 모듈을 사용하여 감지된 피치가 렌더링을 위해 메인 스레드와 React로 이동하는 메커니즘입니다.
src/PitchNode.js
에서 Web Audio API의 내장 AudioWorkletNode
를 서브클래싱합니다.
export default class PitchNode extends AudioWorkletNode { /** * Initialize the Audio processor by sending the fetched WebAssembly module to * the processor worklet. * * @param {ArrayBuffer} wasmBytes Sequence of bytes representing the entire * WASM module that will handle pitch detection. * @param {number} numAudioSamplesPerAnalysis Number of audio samples used * for each analysis. Must be a power of 2. */ init(wasmBytes, onPitchDetectedCallback, numAudioSamplesPerAnalysis) { this.onPitchDetectedCallback = onPitchDetectedCallback; this.numAudioSamplesPerAnalysis = numAudioSamplesPerAnalysis; // Listen to messages sent from the audio processor. this.port.onmessage = (event) => this.onmessage(event.data); this.port.postMessage({ type: "send-wasm-module", wasmBytes, }); } // Handle an uncaught exception thrown in the PitchProcessor. onprocessorerror(err) { console.log( `An error from AudioWorkletProcessor.process() occurred: ${err}` ); }; onmessage(event) { if (event.type === 'wasm-module-loaded') { // The Wasm module was successfully sent to the PitchProcessor running on the // AudioWorklet thread and compiled. This is our cue to configure the pitch // detector. this.port.postMessage({ type: "init-detector", sampleRate: this.context.sampleRate, numAudioSamplesPerAnalysis: this.numAudioSamplesPerAnalysis }); } else if (event.type === "pitch") { // A pitch was detected. Invoke our callback which will result in the UI updating. this.onPitchDetectedCallback(event.pitch); } } }
PitchNode
에서 수행하는 주요 작업은 다음과 같습니다.
- WebAssembly 모듈을 일련의 원시 바이트(
setupAudio.js
에서 전달)로 보내서AudioWorklet
스레드에서 실행되는PitchProcessor
로 보냅니다. 이것이PitchProcessor
가 피치 감지 Wasm 모듈을 로드하는 방법입니다. - PitchProcessor가
PitchProcessor
을 성공적으로 컴파일할 때 보낸 이벤트를 처리하고 피치 감지 구성 정보를 전달하는 다른 이벤트를 보냅니다. -
PitchProcessor
에서 감지된 피치를 처리하고 onPitchDetectedCallback()을 통해 UI 함수setLatestPitch()
onPitchDetectedCallback()
전달합니다.
참고: 이 개체 코드는 메인 스레드에서 실행되므로 비용이 많이 들고 프레임 속도가 떨어지는 경우 감지된 피치에 대해 추가 처리를 수행하지 않아야 합니다.
4. 웹 오디오 설정을 위한 코드 추가
웹 응용 프로그램이 클라이언트 컴퓨터의 마이크에서 실시간 입력에 액세스하고 처리하려면 다음을 수행해야 합니다.
- 브라우저가 연결된 마이크에 액세스할 수 있도록 사용자의 권한을 얻습니다.
- 오디오 스트림 개체로 마이크의 출력에 액세스
- 들어오는 오디오 스트림 샘플을 처리하고 감지된 피치 시퀀스를 생성하는 코드를 첨부합니다.
src/setupAudio.js
에서 이를 수행하고 PitchNode를 연결하기 전에 Wasm 모듈을 비동기식으로 로드하여 PitchNode를 초기화할 수 있습니다.
import PitchNode from "./PitchNode"; async function getWebAudioMediaStream() { if (!window.navigator.mediaDevices) { throw new Error( "This browser does not support web audio or it is not enabled." ); } try { const result = await window.navigator.mediaDevices.getUserMedia({ audio: true, video: false, }); return result; } catch (e) { switch (e.name) { case "NotAllowedError": throw new Error( "A recording device was found but has been disallowed for this application. Enable the device in the browser settings." ); case "NotFoundError": throw new Error( "No recording device was found. Please attach a microphone and click Retry." ); default: throw e; } } } export async function setupAudio(onPitchDetectedCallback) { // Get the browser audio. Awaits user "allowing" it for the current tab. const mediaStream = await getWebAudioMediaStream(); const context = new window.AudioContext(); const audioSource = context.createMediaStreamSource(mediaStream); let node; try { // Fetch the WebAssembly module that performs pitch detection. const response = await window.fetch("wasm-audio/wasm_audio_bg.wasm"); const wasmBytes = await response.arrayBuffer(); // Add our audio processor worklet to the context. const processorUrl = "PitchProcessor.js"; try { await context.audioWorklet.addModule(processorUrl); } catch (e) { throw new Error( `Failed to load audio analyzer worklet at url: ${processorUrl}. Further info: ${e.message}` ); } // Create the AudioWorkletNode which enables the main JavaScript thread to // communicate with the audio processor (which runs in a Worklet). node = new PitchNode(context, "PitchProcessor"); // numAudioSamplesPerAnalysis specifies the number of consecutive audio samples that // the pitch detection algorithm calculates for each unit of work. Larger values tend // to produce slightly more accurate results but are more expensive to compute and // can lead to notes being missed in faster passages ie where the music note is // changing rapidly. 1024 is usually a good balance between efficiency and accuracy // for music analysis. const numAudioSamplesPerAnalysis = 1024; // Send the Wasm module to the audio node which in turn passes it to the // processor running in the Worklet thread. Also, pass any configuration // parameters for the Wasm detection algorithm. node.init(wasmBytes, onPitchDetectedCallback, numAudioSamplesPerAnalysis); // Connect the audio source (microphone output) to our analysis node. audioSource.connect(node); // Connect our analysis node to the output. Required even though we do not // output any audio. Allows further downstream audio processing or output to // occur. node.connect(context.destination); } catch (err) { throw new Error( `Failed to load audio analyzer WASM module. Further info: ${err.message}` ); } return { context, node }; }
This assumes a WebAssembly module is available to be loaded at public/wasm-audio
, which we accomplished in the earlier Rust section.
5. Define the Application UI
Let's define a basic user interface for the pitch detector. We'll replace the contents of src/App.js
with the following code:
import React from "react"; import "./App.css"; import { setupAudio } from "./setupAudio"; function PitchReadout({ running, latestPitch }) { return ( <div className="Pitch-readout"> {latestPitch ? `Latest pitch: ${latestPitch.toFixed(1)} Hz` : running ? "Listening..." : "Paused"} </div> ); } function AudioRecorderControl() { // Ensure the latest state of the audio module is reflected in the UI // by defining some variables (and a setter function for updating them) // that are managed by React, passing their initial values to useState. // 1. audio is the object returned from the initial audio setup that // will be used to start/stop the audio based on user input. While // this is initialized once in our simple application, it is good // practice to let React know about any state that _could_ change // again. const [audio, setAudio] = React.useState(undefined); // 2. running holds whether the application is currently recording and // processing audio and is used to provide button text (Start vs Stop). const [running, setRunning] = React.useState(false); // 3. latestPitch holds the latest detected pitch to be displayed in // the UI. const [latestPitch, setLatestPitch] = React.useState(undefined); // Initial state. Initialize the web audio once a user gesture on the page // has been registered. if (!audio) { return ( <button onClick={async () => { setAudio(await setupAudio(setLatestPitch)); setRunning(true); }} > Start listening </button> ); } // Audio already initialized. Suspend / resume based on its current state. const { context } = audio; return ( <div> <button onClick={async () => { if (running) { await context.suspend(); setRunning(context.state === "running"); } else { await context.resume(); setRunning(context.state === "running"); } }} disabled={context.state !== "running" && context.state !== "suspended"} > {running ? "Pause" : "Resume"} </button> <PitchReadout running={running} latestPitch={latestPitch} /> </div> ); } function App() { return ( <div className="App"> <header className="App-header"> Wasm Audio Tutorial </header> <div className="App-content"> <AudioRecorderControl /> </div> </div> ); } export default App;
And we'll replace App.css
with some basic styles:
.App { display: flex; flex-direction: column; align-items: center; text-align: center; background-color: #282c34; min-height: 100vh; color: white; justify-content: center; } .App-header { font-size: 1.5rem; margin: 10%; } .App-content { margin-top: 15vh; height: 85vh; } .Pitch-readout { margin-top: 5vh; font-size: 3rem; } button { background-color: rgb(26, 115, 232); border: none; outline: none; color: white; margin: 1em; padding: 10px 14px; border-radius: 4px; width: 190px; text-transform: capitalize; cursor: pointer; font-size: 1.5rem; } button:hover { background-color: rgb(45, 125, 252); }
With that, we should be ready to run our app—but there's a pitfall to address first.
WebAssembly/Rust Tutorial: So Close!
Now when we run yarn
and yarn start
, switch to the browser, and attempt to record audio (using Chrome or Chromium, with developer tools open), we're met with some errors:
The first error, TextDecoder is not defined
, occurs when the browser attempts to execute the contents of wasm_audio.js
. This in turn results in the failure to load the Wasm JavaScript wrapper, which produces the second error we see in the console.
The underlying cause of the issue is that modules produced by the Wasm package generator of Rust assume that TextDecoder
(and TextEncoder
) will be provided by the browser. This assumption holds for modern browsers when the Wasm module is being run from the main thread or even a worker thread. However, for worklets (such as the AudioWorklet
context needed in this tutorial), TextDecoder
and TextEncoder
are not yet part of the spec and so are not available.
TextDecoder
is needed by the Rust Wasm code generator to convert from the flat, packed, shared-memory representation of Rust to the string format that JavaScript uses. Put another way, in order to see strings produced by the Wasm code generator, TextEncoder
and TextDecoder
must be defined.
This issue is a symptom of the relative newness of WebAssembly. As browser support improves to support common WebAssembly patterns out of the box, these issues will likely disappear.
For now, we are able to work around it by defining a polyfill for TextDecoder
.
Create a new file public/TextEncoder.js
and import it from public/PitchProcessor.js
:
import "./TextEncoder.js";
Make sure that this import
statement comes before the wasm_audio
import.
Finally, paste this implementation into TextEncoder.js
(courtesy of @Yaffle on GitHub).
The Firefox Question
As mentioned earlier, the way we combine Wasm with Web Audio worklets in our app will not work in Firefox. Even with the above shim, clicking the “Start Listening” button will result in this:
Unhandled Rejection (Error): Failed to load audio analyzer WASM module. Further info: Failed to load audio analyzer worklet at url: PitchProcessor.js. Further info: The operation was aborted.
That's because Firefox doesn't yet support importing modules from AudioWorklets
—for us, that's PitchProcessor.js
running in the AudioWorklet
thread.
The Completed Application
Once done, we simply reload the page. The app should load without error. Click “Start Listening” and allow your browser to access your microphone. You'll see a very basic pitch detector written in JavaScript using Wasm:
Programming in WebAssembly with Rust: A Real-time Web Audio Solution
In this tutorial, we have built a web application from scratch that performs computationally expensive audio processing using WebAssembly. WebAssembly allowed us to take advantage of near-native performance of Rust to perform the pitch detection. Further, this work could be performed on another thread, allowing the main JavaScript thread to focus on rendering to support silky-smooth frame rates even on mobile devices.
Wasm/Rust and Web Audio Takeaways
- Modern browsers provide performant audio (and video) capture and processing inside web apps.
- Rust has great tooling for Wasm, which helps recommend it as the language of choice for projects incorporating WebAssembly.
- Compute-intensive work can be performed efficiently in the browser using Wasm.
Despite the many WebAssembly advantages, there are a couple Wasm pitfalls to watch out for:
- Tooling for Wasm within worklets is still evolving. For example, we needed to implement our own versions of TextEncoder and TextDecoder functionality required for passing strings between JavaScript and Wasm because they were missing from the
AudioWorklet
context. That, and importing Javascript bindings for our Wasm support from anAudioWorklet
is not yet available in Firefox. - Although the application we developed was very simple, building the WebAssembly module and loading it from the
AudioWorklet
required significant setup. Introducing Wasm to projects does introduce an increase in tooling complexity, which is important to keep in mind.
For your convenience, this GitHub repo contains the final, completed project. If you also do back-end development, you may also be interested in using Rust via WebAssembly within Node.js.
Toptal 엔지니어링 블로그에 대한 추가 정보:
- Web Audio API: 코딩할 수 있는데 왜 작곡을 합니까?
- WebVR 3부: WebAssembly 및 AssemblyScript의 잠재력 실현