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

1,482 阅读15分钟

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

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

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

  • 在多个目标平台上使用单一的应用程序代码库,但让应用程序在所有平台上运行良好
  • 创建一个在桌面移动环境下都很流畅和漂亮的用户体验
  • 利用开源库生态系统的优势,避免在应用开发过程中 "重新发明车轮"。

对于前端开发者来说,WebAssembly提供了所有这三点,回答了对真正能与原生移动或桌面体验相媲美的Web应用用户界面的探索。它甚至允许使用用非JavaScript语言编写的库,如C++或Go!

在这个Wasm/Rust教程中,我们将创建一个简单的音高检测器应用,就像一个吉他调音器。它将使用浏览器的内置音频功能,并**以每秒60帧(FPS)的速度运行--甚至在移动设备上。**你不需要了解网络音频API,甚至不需要熟悉Rust,就可以跟上这个教程;但是,希望你能对JavaScript感到满意。

注意:不幸的是,截至本文写作时,本文所使用的技术--专门针对网络音频API--还不能在Firefox中使用。因此,尽管Firefox有很好的Wasm和Web Audio API支持,目前还是建议使用Chrome、Chromium或Edge来学习本教程。

这个WebAssembly/Rust教程的内容包括

  • 在Rust中创建一个简单的函数并从JavaScript中调用它(通过WebAssembly)。
  • 使用现代浏览器的AudioWorklet API,在浏览器中进行高性能音频处理
  • 在JavaScript中的工作者之间进行交流
  • 将这一切结合在一起,形成一个基本的React应用。

注意:如果你对本文的 "如何 "而不是 "为什么 "更感兴趣,可以直接跳到教程中。

为什么是Wasm?

有几个原因可以说明使用WebAssembly的意义:

  • 它允许在浏览器中执行用任何语言编写的代码。
    • 这包括利用现有的库(数字、音频处理、机器学习等),这些库是用JavaScript以外的语言编写的。
  • 根据所使用语言的选择,Wasm能够以接近原生的速度运行。这有可能使网络应用程序的性能特征更接近移动和桌面的本地体验

为什么不总是使用Wasm?

WebAssembly的普及肯定会继续增长;然而,它并不适合所有的Web开发:

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

为什么是Wasm/Rust教程,特别是?

虽然许多编程语言都可以编译成Wasm,但我为这个例子选择了Rust。Rust是由Mozilla在2010年创建的,并在不断地普及。在2020年Stack Overflow的开发者调查中,Rust占据了 "最受喜爱的语言 "的位置。但在WebAssembly中使用Rust的原因不仅仅是潮流:

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

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

WebAssembly的性能:维护如丝般顺滑的网络应用程序

既然我们在WebAssembly中使用Rust编程,那么我们如何使用Rust来获得导致我们首先使用Wasm的性能优势呢?对于一个具有快速更新的GUI的应用程序,要让用户感到 "流畅",它必须能够像屏幕硬件一样定期刷新显示。这通常是60 FPS,所以我们的应用程序必须能够在~16.7毫秒(1,000毫秒/60 FPS)内重新绘制其用户界面。

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

网络音频基础知识

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

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

  • 音频处理通常是计算密集型的。这是由于每秒钟需要处理大量的样本。例如,可靠地检测音频音高需要每秒钟分析44100个样本的频谱。
  • JavaScript的JIT编译和垃圾回收发生在主线程上,我们希望在音频处理代码中避免这种情况,以获得一致的性能。
  • 如果处理一帧音频的时间大大占用了16.7毫秒的帧预算,用户体验就会受到动画不流畅的影响。
  • 我们希望我们的应用程序即使在性能较低的移动设备上也能流畅地运行

网络音频小程序允许应用程序继续实现流畅的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 是一个CLI,用于生成基于React的单页应用程序(SPA)。它使使用React启动一个新的项目变得非常容易。然而,输出的项目包括需要替换的模板代码。

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

应用程序概述

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

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

Wasm音频应用程序概述。

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

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

我们的Rust代码将从一个音频样本阵列中计算出一个音乐音高。

获取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 <you@example.com"]
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应用程序的作用类似于 对JavaScript应用程序的作用。package.json

[package] 部分定义了元数据,在向Rust的官方包注册处发布包时使用。

[lib] 部分描述了Rust编译过程中的输出格式。在这里,"dylib "告诉Rust产生一个 "动态系统库",可以从另一种语言(在我们的例子中是JavaScript)加载,包括 "rlib "告诉Rust添加一个静态库,包含关于产生的库的元数据。对于我们的目的来说,第二个指定符并不是必须的--它有助于开发进一步的Rust模块,这些模块会将这个板条箱作为一个依赖项来使用--但留在其中是安全的。

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

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

在Rust中实现间距检测器

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

与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调用的Pitch检测器

首先,我们将添加一个文件来定义一个有用的工具,其目的我们将在后面讨论。

创建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它的行为是通过impls或traits来描述。

为什么要通过一个对象而不是一个普通的函数来暴露间距检测的功能呢?因为这样一来,我们只需在创建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报告一个错误并立即终止应用程序。在错误处理策略出台之前,使用panics对于早期开发是非常有用的,因为它允许你快速捕捉错误的假设。

在设置过程中调用一次utils::set_panic_hook() 将确保恐慌信息出现在浏览器开发工具中。

接下来,我们定义fft_pad ,即应用于每个分析FFT的零填充量。填充,与算法使用的窗口功能相结合,有助于在分析过程中 "平滑 "传入的采样音频数据的结果。对许多乐器来说,使用FFT长度一半的填充物效果很好。

最后,Rust自动返回最后一条语句的结果,所以WasmPitchDetector 结构语句是new() 的返回值。

我们剩下的impl WasmPitchDetector Rust代码定义了检测音高的API:

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

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

此外,我们的成员函数接收一个任意大小的32位浮点数数组并返回一个数字。这里,这将是在这些样本中计算出的结果音高(单位:Hz):

if audio_samples.len() < self.fft_size {
  panic!("Insufficient samples passed to detect_pitch(). Expected an array containing {} elements but got {}", self.fft_size, audio_samples.len());
}

上面的代码会检测是否有足够的样本提供给函数以进行有效的音高分析。如果没有,就会调用Rustpanic! 宏,导致立即退出Wasm,并将错误信息打印到浏览器dev-tools控制台。

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 worklet中使用该模块,我们将以 选项为目标。其他目标包括为bundler(如webpack)构建或从Node.js消费。我们将从 子目录下运行。web 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模块,准备好被网络应用消耗,或者更具体地说,被PitchProcessor

2.我们的PitchProcessor 类(基于本地AudioWorkletProcessor

在这个应用中,我们将使用一个最近获得广泛的浏览器兼容性的音频处理标准。具体来说,我们将使用网络音频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 之间的事件流。

运行时事件消息。

3.添加网络音频工作单元代码

PitchNode.js 提供了我们自定义音高检测音频处理的接口。 对象是一种机制,使用WebAssembly模块在 线程中工作所检测到的音高将被送到主线程和React中进行渲染。PitchNode AudioWorklet

src/PitchNode.js 中,我们将子类化内置的 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 在成功编译Wasm时发送的事件,并向它发送另一个事件,将间距检测配置信息传递给它。
  • 处理来自PitchProcessor 的检测到的音高,并通过onPitchDetectedCallback() 将它们转发给UI函数setLatestPitch()

注意:该对象的这段代码在主线程上运行,所以应该避免对检测到的投球进行进一步处理,以防这很昂贵,并导致帧率下降。

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 i.e. 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 };
}

这假定一个WebAssembly模块可以在public/wasm-audio ,我们在前面的Rust部分完成了这个加载。

5.定义应用程序的用户界面

让我们为俯仰检测器定义一个基本的用户界面。我们将用以下代码替换src/App.js 的内容。

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;

我们将用一些基本样式替换App.css

.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);
}

有了这些,我们应该准备好运行我们的应用程序,但有一个陷阱要先解决。

WebAssembly/Rust教程。如此接近

现在,当我们运行yarnyarn start ,切换到浏览器,并试图录制音频(使用Chrome或Chromium,打开开发工具),我们会遇到一些错误。

Wasm要求有广泛的支持--只是在Worklet规范中还没有。

第一个错误,TextDecoder is not defined ,发生在浏览器试图执行wasm_audio.js 的内容时。这反过来又导致无法加载Wasm的JavaScript包装器,这产生了我们在控制台看到的第二个错误。

这个问题的根本原因是,Rust的Wasm包生成器产生的模块假设TextDecoder (和TextEncoder )将由浏览器提供。当Wasm模块从主线程甚至工人线程运行时,这个假设对现代浏览器是成立的。然而,对于worklet(比如本教程中需要的AudioWorklet 上下文),TextDecoderTextEncoder 还不是规范的一部分,所以无法使用。

TextDecoder Rust Wasm代码生成器需要将Rust的扁平、包装、共享内存表示转换为JavaScript使用的字符串格式。换句话说,为了看到Wasm代码生成器产生的字符串,必须定义 和TextEncoder TextDecoder

这个问题是WebAssembly相对较新的一个症状。随着浏览器支持的改善,支持常见的WebAssembly模式,这些问题可能会消失。

目前,我们可以通过为TextDecoder 定义一个polyfill来解决这个问题。

创建一个新的文件public/TextEncoder.js 并从public/PitchProcessor.js 中导入它。

import "./TextEncoder.js";

确保这个import 语句是在wasm_audio 的导入之前。

最后,把这个实现粘贴到TextEncoder.js (由GitHub上的@Yaffle提供)。

火狐的问题

如前所述,我们在应用中结合Wasm和Web Audio worklets的方式在Firefox中无法工作。即使有了上面的垫片,点击 "开始聆听 "按钮也会导致这种情况。

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.
    

这是因为Firefox还不支持AudioWorklets中导入模块--对我们来说,这就是在AudioWorklet 线程中运行的PitchProcessor.js

完成的应用程序

一旦完成,我们只需重新加载页面。该应用程序的加载应该没有错误。点击 "开始聆听",让你的浏览器访问你的麦克风。你会看到一个用Wasm编写的非常基本的音调检测器。

实时音调检测。

用Rust在WebAssembly中编程。一个实时的网络音频解决方案

在这个教程中,我们从头开始建立了一个网络应用,用WebAssembly来执行计算量很大的音频处理。WebAssembly使我们能够利用Rust的近乎原生的性能来进行音高检测。此外,这项工作可以在另一个线程上进行,让主要的JavaScript线程专注于渲染,即使在移动设备上也能支持丝般流畅的帧率。

Wasm/Rust和网络音频的启示

  • 现代浏览器在网络应用中提供了高性能的音频(和视频)捕获和处理。
  • Rust对Wasm很好的工具,这有助于推荐它作为包含WebAssembly的项目的首选语言。
  • 计算密集型工作可以在浏览器中使用Wasm有效地进行。

尽管有许多WebAssembly的优点,但也有几个Wasm的陷阱需要注意:

  • 在worklets中的Wasm工具仍在发展中。例如,我们需要实现我们自己版本的TextEncoder和TextDecoder功能,这些功能需要在JavaScript和Wasm之间传递字符串,因为它们在AudioWorklet 。这一点,以及从AudioWorklet ,为我们的Wasm支持导入Javascript绑定,在Firefox中尚不可用。
  • 虽然我们开发的应用程序非常简单,但构建WebAssembly模块和从AudioWorklet 中加载它需要大量的设置。在项目中引入Wasm确实会带来工具复杂性的增加,这一点必须牢记。

为了你的方便,这个GitHub repo包含了最终完成的项目。如果你也做后端开发,你可能也会对在Node.js中通过WebAssembly使用Rust感兴趣。