Учебник по WebAssembly/Rust: идеальная обработка звука
Опубликовано: 2022-03-11Поддерживаемый всеми современными браузерами, WebAssembly (или «Wasm») меняет способ разработки пользовательского интерфейса для Интернета. Это простой двоичный исполняемый формат, который позволяет запускать библиотеки или даже целые программы, написанные на других языках программирования, в веб-браузере.
Разработчики часто ищут способы повысить продуктивность, например:
- Использование единой кодовой базы приложения для нескольких целевых платформ, но при этом приложение должно хорошо работать на всех из них.
- Создание удобного и красивого UX на настольных и мобильных устройствах.
- Использование экосистемы библиотек с открытым исходным кодом, чтобы не «изобретать велосипед» во время разработки приложений.
Для фронтенд-разработчиков WebAssembly предоставляет все три, отвечая на поиск пользовательского интерфейса веб-приложения, который действительно конкурирует с собственным мобильным или настольным интерфейсом. Он даже позволяет использовать библиотеки, написанные на языках, отличных от JavaScript, таких как C++ или Go!
В этом уроке по Wasm/Rust мы создадим простое приложение для определения высоты тона, например, гитарный тюнер. Он будет использовать встроенные звуковые возможности браузера и работать со скоростью 60 кадров в секунду (FPS) даже на мобильных устройствах. Вам не нужно понимать API веб-аудио или даже быть знакомым с Rust, чтобы следовать этому руководству; однако ожидается удобство работы с JavaScript.
Примечание. К сожалению, на момент написания этой статьи методика, используемая в этой статье, относящаяся к API веб-аудио, еще не работала в Firefox. Поэтому на данный момент для этого руководства рекомендуются Chrome, Chromium или Edge, несмотря на отличную поддержку Wasm и Web Audio API в Firefox.
О чем этот учебник по 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 был создан Mozilla в 2010 году и набирает популярность. Rust занимает первое место в рейтинге «самый любимый язык» в опросе разработчиков 2020 года от Stack Overflow. Но причины использовать Rust с WebAssembly выходят за рамки простой модности:
- Прежде всего, у Rust небольшое время выполнения, что означает, что меньше кода отправляется в браузер , когда пользователь заходит на сайт, что помогает уменьшить размер веб-сайта.
- Rust имеет отличную поддержку Wasm, поддерживая взаимодействие высокого уровня с JavaScript.
- Rust обеспечивает производительность, близкую к уровню C/C++ , но имеет очень безопасную модель памяти . По сравнению с другими языками, Rust выполняет дополнительные проверки безопасности при компиляции вашего кода, что значительно снижает вероятность сбоев, вызванных пустыми или неинициализированными переменными. Это может привести к более простой обработке ошибок и более высокой вероятности поддержания хорошего UX при возникновении непредвиденных проблем.
- Rust не является сборщиком мусора . Это означает, что код Rust полностью контролирует выделение и очистку памяти, обеспечивая стабильную производительность — ключевое требование в системах реального времени.
Многие преимущества Rust также связаны с крутой кривой обучения, поэтому выбор правильного языка программирования зависит от множества факторов, таких как состав команды, которая будет разрабатывать и поддерживать код.
Производительность WebAssembly: обеспечение бесперебойной работы веб-приложений
Поскольку мы программируем на WebAssembly с Rust, как мы можем использовать Rust, чтобы получить преимущества в производительности, которые привели нас к Wasm? Чтобы приложение с быстро обновляющимся графическим интерфейсом казалось пользователям «гладким», оно должно иметь возможность обновлять дисплей так же регулярно, как и аппаратное обеспечение экрана. Обычно это 60 кадров в секунду, поэтому наше приложение должно иметь возможность перерисовывать свой пользовательский интерфейс в течение ~ 16,7 мс (1000 мс / 60 кадров в секунду).
Наше приложение определяет и показывает текущий шаг в режиме реального времени, что означает, что комбинированное вычисление обнаружения и отрисовка должны оставаться в пределах 16,7 мс на кадр. В следующем разделе мы воспользуемся поддержкой браузера для анализа звука в другом потоке , пока основной поток выполняет свою работу. Это большой выигрыш в производительности, поскольку вычисления и рисование имеют в своем распоряжении по 16,7 мс.
Основы веб-аудио
В этом приложении мы будем использовать высокопроизводительный аудиомодуль WebAssembly для определения высоты тона. Кроме того, мы обеспечим, чтобы вычисления не выполнялись в основном потоке.
Почему мы не можем упростить задачу и выполнить определение высоты тона в основном потоке?
- Обработка звука часто требует больших вычислительных ресурсов. Это связано с большим количеством выборок, которые необходимо обрабатывать каждую секунду. Например, для надежного определения высоты звука требуется анализировать спектры 44 100 выборок каждую секунду.
- JIT-компиляция и сборка мусора JavaScript происходят в основном потоке, и мы хотим избежать этого в коде обработки звука для стабильной производительности.
- Если бы время, затрачиваемое на обработку кадра аудио, значительно съело бюджет кадра в 16,7 мс, UX страдал бы от прерывистой анимации.
- Мы хотим, чтобы наше приложение работало без сбоев даже на мобильных устройствах с низкой производительностью!
Ворклеты веб-аудио позволяют приложениям продолжать достигать плавных 60 кадров в секунду, поскольку обработка звука не может задерживать основной поток. Если обработка звука слишком медленная и отстает, будут другие эффекты, такие как отставание звука. Тем не менее, 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
для выполнения команды create-react-app
(содержащейся в соответствующем пакете, поддерживаемом Facebook) для создания нового приложения React в каталоге wasm-audio-app
.
create-react-app
— это интерфейс командной строки для создания одностраничных приложений (SPA) на основе React. С ним невероятно легко начать новый проект с React. Однако выходной проект включает шаблонный код, который необходимо заменить.
Во-первых, хотя я настоятельно рекомендую модульное тестирование вашего приложения на протяжении всей разработки, тестирование выходит за рамки этого руководства. Итак, мы продолжим и удалим src/App.test.js
и src/setupTests.js
.
Обзор приложения
В нашем приложении будет пять основных компонентов JavaScript:
-
public/wasm-audio/wasm-audio.js
содержит привязки JavaScript к модулю Wasm, обеспечивающему алгоритм обнаружения основного тона. -
public/PitchProcessor.js
— это место, где происходит обработка звука. Он запускается в потоке рендеринга веб-аудио и использует Wasm API. -
src/PitchNode.js
содержит реализацию узла веб-аудио, который подключен к графу веб-аудио и работает в основном потоке. -
src/setupAudio.js
использует API-интерфейсы веб-браузера для доступа к доступному устройству аудиозаписи. -
src/App.js
иsrc/App.css
составляют пользовательский интерфейс приложения.
Давайте углубимся прямо в сердце нашего приложения и определим код Rust для нашего модуля Wasm. Затем мы закодируем различные части нашего JavaScript, связанного с веб-аудио, и закончим пользовательским интерфейсом.
1. Обнаружение шага с помощью Rust и WebAssembly
Наш код на Rust будет вычислять музыкальную высоту из массива аудиосэмплов.
Получить ржавчину
Вы можете следовать этим инструкциям, чтобы построить цепочку Rust для разработки.
Установите инструменты для сборки компонентов WebAssembly в Rust
wasm-pack
позволяет создавать, тестировать и публиковать компоненты WebAssembly, созданные на Rust. Если вы еще этого не сделали, установите wasm-pack.
cargo-generate
помогает запустить новый проект Rust, используя уже существующий репозиторий Git в качестве шаблона. Мы будем использовать это для начальной загрузки простого аудиоанализатора на Rust, доступ к которому можно получить с помощью WebAssembly из браузера.
Используя инструмент cargo
, поставляемый с цепочкой Rust, вы можете установить 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 называет «ящиком»), выполняя аналогичную функцию для приложений Rust, которую package.json
выполняет для приложений JavaScript.
Раздел [package]
определяет метаданные, которые используются при публикации пакета в официальном реестре пакетов Rust.
Раздел [lib]
описывает выходной формат процесса компиляции Rust. Здесь «cdylib» говорит Rust создать «динамическую системную библиотеку», которую можно загрузить с другого языка (в нашем случае, JavaScript), а включение «rlib» говорит Rust добавить статическую библиотеку, содержащую метаданные о созданной библиотеке. Этот второй спецификатор не нужен для наших целей — он помогает в разработке дополнительных модулей Rust, которые используют этот крейт как зависимость, — но его можно безопасно оставить.
В [features]
мы просим Rust включить необязательную функцию console_error_panic_hook
, чтобы обеспечить функциональность, которая преобразует механизм необработанных ошибок Rust (называемый panic
) в консольные ошибки, которые отображаются в инструментах разработки для отладки.
Наконец, [dependencies]
перечисляет все ящики, от которых зависит этот. Единственная готовая зависимость — это wasm-bindgen
, которая обеспечивает автоматическое создание привязок JavaScript к нашему модулю Wasm.
Реализовать детектор шага в Rust
Цель этого приложения — определить голос музыканта или высоту звука инструмента в режиме реального времени. Чтобы это выполнялось как можно быстрее, перед модулем WebAssembly стоит задача расчета шага. Для обнаружения одноголосного основного тона мы будем использовать метод основного тона «McLeod», реализованный в существующей библиотеке pitch-detection
Rust.
Подобно диспетчеру пакетов Node.js (npm), Rust включает в себя собственный менеджер пакетов, который называется Cargo. Это позволяет легко устанавливать пакеты, которые были опубликованы в реестре ящиков Rust.
Чтобы добавить зависимость, отредактируйте Cargo.toml
, добавив в раздел зависимостей строку для pitch-detection
:
[dependencies] wasm-bindgen = "0.2.63" pitch-detection = "0.1"
Это указывает Cargo на загрузку и установку зависимости pitch-detection
во время следующей cargo build
Cargo или, поскольку мы ориентируемся на WebAssembly, это будет выполнено в следующем wasm-pack
.
Создайте в Rust детектор шага, вызываемый JavaScript
Сначала мы добавим файл, определяющий полезную утилиту, назначение которой мы обсудим позже:
Создайте wasm-audio/src/utils.rs
и вставьте в него содержимое этого файла.
Мы заменим сгенерированный код в wasm-audio/lib.rs
следующим кодом, который выполняет определение высоты тона с помощью алгоритма быстрого преобразования Фурье (БПФ):
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
— это макрос Rust, который помогает реализовать привязку между JavaScript и Rust. При компиляции в WebAssembly этот макрос указывает компилятору создать привязку JavaScript к классу. Приведенный выше код Rust будет транслироваться в привязки JavaScript, которые представляют собой просто тонкие оболочки для вызовов в модуль Wasm и из него. Легкий уровень абстракции в сочетании с прямой общей памятью между JavaScript — вот что помогает Wasm обеспечивать отличную производительность.
#[wasm_bindgen] pub struct WasmPitchDetector { sample_rate: usize, fft_size: usize, detector: McLeodDetector<f32>, } #[wasm_bindgen] impl WasmPitchDetector { ... }
В Rust нет понятия классов. Скорее, данные объекта описываются struct
, а его поведение — через impl
или trait
.
Зачем раскрывать функциональность обнаружения высоты тона через объект, а не через простую функцию? Потому что таким образом мы только один раз инициализируем структуры данных, используемые внутренним McLeodDetector , во время создания WasmPitchDetector
. Это обеспечивает быструю работу функции 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_pad
, величину заполнения нулями, применяемую к каждому анализу БПФ. Заполнение в сочетании с функцией управления окнами, используемой алгоритмом, помогает «сгладить» результаты по мере того, как анализ перемещается по входящим дискретным аудиоданным. Использование пэда половинной длины БПФ хорошо работает для многих инструментов.
Наконец, Rust автоматически возвращает результат последнего оператора, поэтому оператор структуры WasmPitchDetector
является возвращаемым значением new()
.
Остальная часть нашего impl WasmPitchDetector
Rust определяет API для обнаружения шагов:
pub fn detect_pitch(&mut self, audio_samples: Vec<f32>) -> f32 { ... }
Вот как выглядит определение функции-члена в Rust. В detect_pitch
добавляется публичный член WasmPitchDetector
. Его первый аргумент — это изменяемая ссылка ( &mut
) на созданный объект того же типа, содержащий поля struct
и impl
, но он передается автоматически при вызове, как мы увидим ниже.
Кроме того, наша функция-член принимает массив 32-битных чисел с плавающей запятой произвольного размера и возвращает одно число. Здесь это будет результирующая высота тона, рассчитанная для этих сэмплов (в Гц).
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()); }
Вышеприведенный код определяет, было ли предоставлено достаточное количество выборок функции для выполнения действительного анализа основного тона. Если нет, то ржавая panic!
вызывается макрос, что приводит к немедленному выходу из Wasm и сообщению об ошибке, которое выводится в консоль инструментов разработчика браузера.
let optional_pitch = self.detector.get_pitch( &audio_samples, self.sample_rate, POWER_THRESHOLD, CLARITY_THRESHOLD, );
Это вызывает стороннюю библиотеку для расчета высоты тона из последних аудиосэмплов. POWER_THRESHOLD
и CLARITY_THRESHOLD
можно настроить для настройки чувствительности алгоритма.
Мы заканчиваем подразумеваемым возвратом значения с плавающей запятой через ключевое слово match
, которое работает аналогично оператору switch
в других языках. Some()
и None
позволяют нам правильно обрабатывать случаи, не сталкиваясь с исключением нулевого указателя.
Создание приложений WebAssembly
При разработке приложений на Rust обычная процедура сборки заключается в вызове сборки с помощью cargo build
. Однако мы создаем модуль Wasm, поэтому воспользуемся wasm-pack
, который обеспечивает более простой синтаксис при работе с Wasm. (Это также позволяет публиковать полученные привязки JavaScript в реестре npm, но это выходит за рамки этого руководства.)
wasm-pack
поддерживает различные цели сборки. Поскольку мы будем использовать модуль непосредственно из рабочего файла Web Audio, мы нацелимся на web
вариант. Другие цели включают сборку для сборщика, такого как веб-пакет, или для использования из Node.js. Мы запустим это из wasm-audio/
:
wasm-pack build --target web
В случае успеха модуль npm создается в ./pkg
.
Это модуль JavaScript с собственным автоматически сгенерированным package.json
. При желании его можно опубликовать в реестре npm. Для простоты мы можем просто скопировать и вставить этот pkg
в нашу папку public/wasm-audio
:

cp -R ./wasm-audio/pkg ./public/wasm-audio
Таким образом, мы создали модуль Rust Wasm, готовый к использованию веб-приложением, или, точнее, PitchProcessor
.
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
:
- Обрабатывает событие
"send-wasm-module"
, отправленное изPitchNode
, путем компиляции и загрузки модуля Wasm в рабочий файл. После этого он сообщает об этомPitchNode
, отправляя"wasm-module-loaded"
. Этот подход с обратным вызовом необходим, потому что вся связь междуPitchNode
иPitchProcessor
пересекает границу потока и не может выполняться синхронно. - Также реагирует на событие
"init-detector"
отPitchNode
, настраиваяWasmPitchDetector
. - Обрабатывает аудиосэмплы, полученные из аудиографа браузера, делегирует вычисление определения высоты тона модулю Wasm, а затем отправляет любую обнаруженную высоту звука обратно в
PitchNode
(который отправляет высоту звука вместе с уровнем React через свойonPitchDetectedCallback
). - Регистрирует себя под определенным уникальным именем. Таким образом, браузер знает — через базовый класс
PitchNode
, собственныйAudioWorkletNode
— как создать экземпляр нашегоPitchProcessor
позже, когдаPitchNode
будет создан. См.setupAudio.js
.
Следующая диаграмма визуализирует поток событий между PitchNode
и PitchProcessor
:
3. Добавьте рабочий код веб-аудио
PitchNode.js
предоставляет интерфейс для нашей пользовательской обработки звука с определением высоты тона. Объект PitchNode
— это механизм, с помощью которого питчи, обнаруженные с помощью модуля WebAssembly, работающего в потоке AudioWorklet
, направляются в основной поток и React для рендеринга.
В src/PitchNode.js
мы создадим подкласс встроенного AudioWorkletNode
API веб-аудио:
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
, вPitchProcessor
, который выполняется в потокеAudioWorklet
. Вот какPitchProcessor
загружает модуль Wasm для определения высоты тона. - Обработайте событие, отправленное
PitchProcessor
, когда он успешно скомпилирует Wasm, и отправьте ему другое событие, которое передает ему информацию о конфигурации обнаружения основного тона. - Обрабатывайте обнаруженные питчи по мере их поступления от
PitchProcessor
и перенаправляйте их в функцию пользовательского интерфейсаsetLatestPitch()
черезonPitchDetectedCallback()
.
Примечание. Этот код объекта выполняется в основном потоке, поэтому следует избегать дальнейшей обработки обнаруженных шагов, если это дорого и вызывает падение частоты кадров.
4. Добавьте код для настройки веб-аудио
Чтобы веб-приложение могло получать доступ и обрабатывать живой ввод с микрофона клиентской машины, оно должно:
- Получите разрешение пользователя на доступ браузера к любому подключенному микрофону
- Доступ к выходу микрофона как к объекту аудиопотока
- Прикрепите код для обработки образцов входящего аудиопотока и создания последовательности обнаруженных тонов.
В src/setupAudio.js
мы сделаем это, а также асинхронно загрузим модуль Wasm, чтобы мы могли инициализировать с его помощью наш PitchNode перед присоединением нашего 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 Engineering:
- API веб-аудио: зачем сочинять, если можно кодить?
- WebVR, часть 3: раскрытие потенциала WebAssembly и AssemblyScript