WebAssembly/Rust 教程:音高完美的音频处理

已发表: 2022-03-11

在所有现代浏览器的支持下,WebAssembly(或“Wasm”)正在改变我们为 Web 开发用户体验的方式。 它是一种简单的二进制可执行格式,允许使用其他编程语言编写的库甚至整个程序在 Web 浏览器中运行。

开发人员经常寻找提高生产力的方法,例如:

  • 为多个目标平台使用单个应用程序代码库,但让应用程序在所有目标平台上运行良好
  • 在桌面移动环境中创建流畅美观的用户体验
  • 利用开源库生态系统避免在应用程序开发过程中“重新发明轮子”

对于前端开发人员,WebAssembly 提供了所有这三个功能,可以满足对真正可与原生移动或桌面体验相媲美的 Web 应用程序 UI 的搜索。 它甚至允许使用以非 JavaScript 语言编写的库,例如 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 能够以接近原生的速度运行。 这有可能使 Web 应用程序的性能特征更接近移动和桌面的原生体验

为什么不总是使用 Wasm?

WebAssembly 的受欢迎程度肯定会继续增长; 但是,它并不适合所有 Web 开发:

  • 对于简单的项目,坚持使用 JavaScript、HTML 和 CSS 可能会在更短的时间内交付可用的产品。
  • 较旧的浏览器(例如 Internet Explorer)不直接支持 Wasm。
  • WebAssembly 的典型用途需要将工具(例如语言编译器)添加到您的工具链中。 如果您的团队优先考虑保持开发和持续集成工具尽可能简单,那么使用 Wasm 将与此背道而驰。

为什么是 Wasm/Rust 教程?

虽然许多编程语言都编译为 Wasm,但我在这个示例中选择了 Rust。 Rust 由 Mozilla 于 2010 年创建,并且越来越受欢迎。 在 Stack Overflow 的 2020 年开发者调查中,Rust 占据了“最受欢迎语言”的首位。 但是将 Rust 与 WebAssembly 结合使用的原因不仅仅是流行:

  • 首先,Rust 的运行时间很小,这意味着当用户访问该站点时,向浏览器发送的代码更少,有助于保持网站占用空间较小。
  • Rust 具有出色的 Wasm 支持,支持与 JavaScript的高级互操作性
  • Rust 提供接近C/C++ 级别的性能,但具有非常安全的内存模型。 与其他语言相比,Rust 在编译代码时会执行额外的安全检查,从而大大降低了由空变量或未初始化变量引起的崩溃的可能性。 这可以导致更简单的错误处理和更高的机会在出现意外问题时保持良好的用户体验。
  • Rust不是垃圾收集的。 这意味着 Rust 代码可以完全控制何时分配和清理内存,从而实现一致的性能——这是实时系统的关键要求。

Rust 的许多好处也伴随着陡峭的学习曲线,因此选择正确的编程语言取决于多种因素,例如将开发和维护代码的团队的组成。

WebAssembly 性能:维护如丝般流畅的 Web 应用程序

由于我们使用 Rust 在 WebAssembly 中编程,我们如何使用 Rust 来获得最初导致我们使用 Wasm 的性能优势? 对于具有快速更新 GUI 的应用程序要让用户感觉“流畅”,它必须能够像屏幕硬件一样定期刷新显示。 这通常是 60 FPS,因此我们的应用程序必须能够在 ~16.7 ms (1,000 ms / 60 FPS) 内重绘其用户界面。

我们的应用程序实时检测并显示当前音高,这意味着组合检测计算和绘图必须保持在每帧 16.7 毫秒内。 在下一节中,我们将利用浏览器支持来分析另一个线程上的音频,同时主线程执行其工作。 这是性能的重大胜利,因为计算和绘图各有16.7 毫秒可供使用。

网络音频基础

在这个应用程序中,我们将使用高性能 WebAssembly 音频模块来执行音高检测。 此外,我们将确保计算不在主线程上运行。

为什么我们不能保持简单并在主线程上执行音高检测?

  • 音频处理通常是计算密集型的。 这是由于每秒需要处理大量样本。 例如,可靠地检测音频音高需要每秒分析 44,100 个样本的频谱。
  • JavaScript 的 JIT 编译和垃圾回收发生在主线程上,我们希望在音频处理代码中避免这种情况,以获得一致的性能。
  • 如果处理一帧音频所花费的时间显着占用 16.7 毫秒的帧预算,则 UX 将受到动画断断续续的影响。
  • 我们希望我们的应用程序即使在性能较低的移动设备上也能顺利运行!

Web Audio worklet 允许应用程序继续实现流畅的 60 FPS,因为音频处理无法阻止主线程。 如果音频处理太慢而落后,还会出现其他影响,例如音频滞后。 但是,用户体验将保持对用户的响应。

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 维护的相应包中)以在目录wasm-audio-app中创建一个新的 React 应用程序。

create-react-app是一个用于生成基于 React 的单页应用程序 (SPA) 的 CLI。 它使使用 React 开始一个新项目变得异常容易。 但是,输出项目包含需要替换的样板代码。

首先,虽然我强烈建议在整个开发过程中对您的应用程序进行单元测试,但测试超出了本教程的范围。 所以我们将继续删除src/App.test.jssrc/setupTests.js

应用概述

我们的应用程序中有五个主要的 JavaScript 组件:

  • public/wasm-audio/wasm-audio.js包含与提供音高检测算法的 Wasm 模块的 JavaScript 绑定。
  • public/PitchProcessor.js是音频处理发生的地方。 它在 Web Audio 渲染线程中运行,并将使用 Wasm API。
  • src/PitchNode.js包含一个 Web Audio 节点的实现,它连接到 Web Audio 图并在主线程中运行。
  • src/setupAudio.js使用 Web 浏览器 API 来访问可用的录音设备。
  • src/App.jssrc/App.css组成了应用程序用户界面。

音高检测应用程序的流程图。块 1 和 2 在 Web 音频线程上运行。第 1 块是 Wasm (Rust) Pitch Detector,在文件 wasm-audio/lib.rs 中。第 2 块是 Web 音频检测 + 通信,在文件 PitchProcessor.js 中。它要求检测器初始化,检测器将检测到的音高发送回 Web 音频接口。块 3、4 和 5 在主线程上运行。第 3 块是网络音频控制器,在文件 PitchNode.js 中。它将 Wasm 模块发送到 PitchProcessor.js,并从中接收检测到的音高。第 4 块是 setupAudio.js 中的 Web 音频设置。它创建一个 PitchNode 对象。第 5 块是 Web 应用程序 UI,由 App.js 和 App.css 组成。它在启动时调用 setupAudio.js。它还通过向 PitchNode 发送消息来暂停或恢复音频录制,从中接收检测到的音高以显示给用户。
Wasm 音频应用概述。

让我们直接深入到我们应用程序的核心,并为我们的 Wasm 模块定义 Rust 代码。 然后,我们将对与 Web 音频相关的 JavaScript 的各个部分进行编码,并以 UI 结束。

1. 使用 Rust 和 WebAssembly 进行音高检测

我们的 Rust 代码将从一组音频样本中计算音高。

生锈

您可以按照这些说明构建用于开发的 Rust 链。

安装用于在 Rust 中构建 WebAssembly 组件的工具

wasm-pack允许你构建、测试和发布 Rust 生成的 WebAssembly 组件。 如果您还没有,请安装 wasm-pack。

cargo-generate通过利用预先存在的 Git 存储库作为模板来帮助启动和运行一个新的 Rust 项目。 我们将使用它在 Rust 中引导一个简单的音频分析器,可以使用 WebAssembly 从浏览器访问它。

使用 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”),为 Rust 应用程序提供与package.json为 JavaScript 应用程序提供的类似功能。

[package]部分定义了将包发布到 Rust 的官方包注册表时使用的元数据。

[lib]部分描述了 Rust 编译过程的输出格式。 在这里,“cdylib”告诉 Rust 生成一个可以从另一种语言(在我们的例子中为 JavaScript)加载的“动态系统库”,并且包含“rlib”告诉 Rust 添加一个包含有关生成的库的元数据的静态库。 第二个说明符对于我们的目的来说不是必需的——它有助于开发进一步的 Rust 模块,这些模块将这个 crate 作为依赖项使用——但可以安全地离开。

[features]中,我们要求 Rust 包含一个可选功能console_error_panic_hook以提供将 Rust 的未处理错误机制(称为panic )转换为显示在开发工具中进行调试的控制台错误的功能。

最后, [dependencies]列出了这个依赖的所有 crate。 开箱即用的唯一依赖项是wasm-bindgen ,它为我们的 Wasm 模块提供 JavaScript 绑定的自动生成。

在 Rust 中实现音高检测器

这个应用程序的目的是能够实时检测音乐家的声音或乐器的音高。 为了确保尽快执行,WebAssembly 模块的任务是计算音高。 对于单音音高检测,我们将使用在现有 Rust pitch-detection库中实现的“McLeod”音高方法。

与 Node.js 包管理器 (npm) 非常相似,Rust 包含一个自己的包管理器,称为 Cargo。 这允许轻松安装已发布到 Rust crate 注册表的包。

要添加依赖项,请编辑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是一个 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及其行为通过impltrait来描述。

为什么要通过对象而不是普通函数来公开音高检测功能? 因为那样,我们只在创建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_pad ,即应用于每个分析 FFT 的零填充量。 当分析在传入的采样音频数据中移动时,填充与算法使用的窗口函数相结合,有助于“平滑”结果。 使用 FFT 长度一半的焊盘适用于许多仪器。

最后,Rust 会自动返回最后一条语句的结果,因此WasmPitchDetector struct 语句就是new()的返回值。

我们的impl WasmPitchDetector Rust 代码的其余部分定义了用于检测音高的 API:

 pub fn detect_pitch(&mut self, audio_samples: Vec<f32>) -> f32 { ... }

这就是 Rust 中成员函数定义的样子。 公共成员detect_pitch添加到WasmPitchDetector 。 它的第一个参数是对包含structimpl字段的相同类型的实例化对象的可变引用 ( &mut ),但在调用时会自动传递,如下所示。

此外,我们的成员函数接受一个任意大小的 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()); }

上面的代码检测是否为函数提供了足够的样本以执行有效的音高分析。 如果没有,Rust panic! 调用宏会立即退出 Wasm,并将错误消息打印到浏览器开发工具控制台。

 let optional_pitch = self.detector.get_pitch( &audio_samples, self.sample_rate, POWER_THRESHOLD, CLARITY_THRESHOLD, );

这会调用第三方库从最新的音频样本中计算音高。 可以调整POWER_THRESHOLDCLARITY_THRESHOLD以调整算法的灵敏度。

我们以通过match关键字隐式返回浮点值结束,其工作方式类似于其他语言中的switch语句。 Some()None让我们可以适当地处理案例,而不会遇到空指针异常。

构建 WebAssembly 应用程序

在开发 Rust 应用程序时,通常的构建过程是使用cargo build调用构建。 但是,我们正在生成一个 Wasm 模块,因此我们将使用wasm-pack ,它在针对 Wasm 时提供更简单的语法。 (它还允许将生成的 JavaScript 绑定发布到 npm 注册表,但这超出了本教程的范围。)

wasm-pack支持多种构建目标。 因为我们将直接从 Web Audio 工作集中使用模块,所以我们将针对web选项。 其他目标包括为 webpack 等捆绑器构建或从 Node.js 消费。 我们将从wasm-audio/子目录运行它:

 wasm-pack build --target web

如果成功,则会在./pkg下创建一个 npm 模块。

这是一个 JavaScript 模块,具有自己的自动生成的package.json 。 如果需要,可以将其发布到 npm 注册表。 现在为了简单起见,我们可以简单地将这个pkg复制并粘贴到我们的文件夹public/wasm-audio下:

 cp -R ./wasm-audio/pkg ./public/wasm-audio

有了这个,我们创建了一个 Rust Wasm 模块,可供 Web 应用程序使用,或者更具体地说,由PitchProcessor

2.我们的PitchProcessor类(基于 Native 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);

PitchProcessorPitchNode的伴侣,但在单独的线程中运行,因此可以在不阻塞主线程上完成的工作的情况下执行音频处理计算。

主要是PitchProcessor

  • 通过将 Wasm 模块编译并加载到工作集中来处理从PitchNode发送的"send-wasm-module"事件。 完成后,它通过发送"wasm-module-loaded"事件让PitchNode知道。 需要这种回调方法是因为PitchNodePitchProcessor之间的所有通信都跨越了线程边界并且不能同步执行。
  • 还通过配置WasmPitchDetector来响应来自PitchNode"init-detector"事件。
  • 处理从浏览器音频图接收到的音频样本,将音高检测计算委托给 Wasm 模块,然后将任何检测到的音高发送回PitchNode (它通过其onPitchDetectedCallback将音高发送到 React 层)。
  • 以特定的唯一名称注册自己。 通过这种方式,浏览器知道——通过PitchNode的基类,即本机AudioWorkletNode稍后在构造PitchNode时如何实例化我们的PitchProcessor 。 请参阅setupAudio.js

下图可视化了PitchNodePitchProcessor之间的事件流:

比较 PitchNode 和 PitchProcess 对象在运行时的交互的更详细的流程图。在初始设置期间,PitchNode 将 Wasm 模块作为字节数组发送到 PitchProcessor,PitchProcessor 编译它们并将它们发送回 PitchNode,最终以请求 PitchProcessor 初始化自身的事件消息进行响应。在录制音频时,PitchNode 不发送任何内容,并从 PitchProcessor 接收两种类型的事件消息:检测到的音高或错误,如果发生来自 Wasm 或工作集。
运行时事件消息。

3.添加Web Audio Worklet代码

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传入的字节序列)发送到PitchProcessor ,后者在AudioWorklet线程上运行。 这就是PitchProcessor加载音高检测 Wasm 模块的方式。
  • 处理成功编译PitchProcessor时 PitchProcessor 发送的事件,并向其发送另一个传递音高检测配置信息的事件。
  • 处理从PitchProcessor到达的检测到的音高,并通过 onPitchDetectedCallback() 将它们转发到 UI 函数setLatestPitch() onPitchDetectedCallback()

注意:此对象代码在主线程上运行,因此应避免对检测到的音高执行进一步处理,以防代价高昂并导致帧速率下降。

4. 添加代码以设置网络音频

为了让 Web 应用程序能够访问和处理来自客户端机器麦克风的实时输入,它必须:

  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.

完成的申请

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 工程博客:

  • Web Audio API:为什么要编写代码?
  • WebVR 第 3 部分:释放 WebAssembly 和 AssemblyScript 的潜力