Учебник по 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 составляют пользовательский интерфейс приложения.

Блок-схема приложения для обнаружения высоты тона. Блоки 1 и 2 выполняются в потоке веб-аудио. Блок 1 — детектор высоты тона Wasm (ржавчины) в файле wasm-audio/lib.rs. Блок 2 — это обнаружение веб-аудио + связь в файле PitchProcessor.js. Он запрашивает у детектора инициализацию, и детектор отправляет обнаруженные ноты обратно в интерфейс Web Audio. Блоки 3, 4 и 5 выполняются в основном потоке. Блок 3 — это контроллер веб-аудио в файле PitchNode.js. Он отправляет модуль Wasm в PitchProcessor.js и получает от него обнаруженные питчи. Блок 4 — это настройка веб-аудио в setupAudio.js. Он создает объект PitchNode. Блок 5 — это пользовательский интерфейс веб-приложения, состоящий из App.js и App.css. Он вызывает setupAudio.js при запуске. Он также приостанавливает или возобновляет аудиозапись, отправляя сообщение на узел PitchNode, откуда он получает обнаруженные звуки для отображения пользователю.
Обзор аудиоприложения Wasm.

Давайте углубимся прямо в сердце нашего приложения и определим код 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 :

Более подробная блок-схема, сравнивающая взаимодействие между объектами PitchNode и PitchProcess во время выполнения. Во время первоначальной настройки PitchNode отправляет модуль Wasm в виде массива байтов в PitchProcessor, который компилирует их и отправляет обратно в PitchNode, который, наконец, отвечает сообщением о событии с запросом на инициализацию PitchProcessor. Во время записи звука PitchNode ничего не отправляет и получает два типа сообщений о событиях от PitchProcessor: Обнаруженная высота звука или ошибка, если она возникает либо из Wasm, либо из worklet.
Сообщения о событиях во время выполнения.

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. Добавьте код для настройки веб-аудио

Чтобы веб-приложение могло получать доступ и обрабатывать живой ввод с микрофона клиентской машины, оно должно:

  1. Получите разрешение пользователя на доступ браузера к любому подключенному микрофону
  2. Доступ к выходу микрофона как к объекту аудиопотока
  3. Прикрепите код для обработки образцов входящего аудиопотока и создания последовательности обнаруженных тонов.

В 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:

At wasm_audio.js line 24 there's the error, "Uncaught ReferenceError: TextDecoder is not defined," followed by one at setupAudio.js line 84 triggered by the async onClick from App.js line 43, which reads, "Uncaught (in promise) Error: Failed to load audio analyzer WASM module. Further info: Failed to construct 'AudioWorkletNode': AudioWorkletNode cannot be created: The node name 'PitchProcessor' is not defined in AudioWorkletGlobalScope."
Wasm requirements have wide support—just not yet in the Worklet spec.

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:

A screenshot of the app showing its title,
Real-time pitch detection.

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 an AudioWorklet 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