LoginSignup
44
18

More than 5 years have passed since last update.

WebAssembly / Rust / AudioWorklet でシンセ作ってみる

Posted at

この記事はLivesense AdvendCalendar 21 日目の記事です。

表題の通り、AudioWorklet で動作するような WebAssembly によるシンセを Rust で作ってみようというやつです。
出来たものはこちら。
https://reprimande.github.io/wasm-audioworklet-synth/

UI は g200kg 先生 の webaudio-controls を使わせていただきましたがメチャメチャ便利です。

Rust で WebAssembly を作る

現在の Rust ではコンパイル時のターゲットに wasm32-unknown-unknown を指定すると Emscripten なしで WebAssembly にコンパイル出来る機能が単体で備わってるので Rust で頑張っていきます。

さらに最近では wasm-bindgen / js-sys / web-sys / wasm-pack といった JavaScript との連携周りをより簡潔に行えるようにするライブラリも増えてきているようで Rust で WebAssembly を作るのはより簡単になってきています。

ただ、今回は WebAssembly の開発自体が初めてなので出来るだけ言語 / ブラウザ標準の機能での動作や WebAssembly を扱う時に言語に依存しない部分の仕組みも分かりたい、といった理由でこれらを使わないで単体での機能 + wasm-gc でのバイナリの容量の削減のみで進めます。

そうすると、今回の Rust で WebAssembly を作る手順としては以下のようにな感じです。各々詳細に解説しませんが良い記事やドキュメントなど他にあるので気になる人は調べて下さい ><

Cargo.toml

...
[lib]
crate-type = ["cdylib"]
...

src/lib.rs

...
#[no_mangle]
pub extern "C" fn alloc(size: usize) -> *mut f32 {
...
}
...

ビルド

$ cargo build --target wasm32-unknown-unknown --release
$ wasm-gc target/wasm32-unknown-unknown/release/wasm_audioworklet_synth.wasm -o wasm/wasm_audioworklet_synth.wasm

WebAssembly を AudioWorklet で使う

折角なので deprecated な ScriptProcessorNode ではなく AudioWorklet を使ってやっていきます。
AudioWorklet はオーディオ処理をそれ専用のスレッドで動作させるための機能です。WebWorker / ServiceWorker のオーディオ版と思うとイメージしやすいかと思います。

AudioWorklet は process という関数をオーバーライドすることにより、決まったサンプル分の信号処理をそこに記述出来、それが止めどなく実行されるというものなのですが、今回はそこで実行される全ての音声信号処理 WebAssembly / Rust 側で行うこととします。

WebAssembly を AudioWorklet で使う場合どうすれば良いのかと考えましたが、Enter Audio Worklet の MessagePort の項 で WASM モジュールを転送するのにも使えるよねって書いてあったのでそれでやってみます。

app.js

const ctx = new AudioContext()
ctx.audioWorklet.addModule('js/processor.js').then(() => {
  const n = new AudioWorkletNode(ctx, 'my-processor')
  n.connect(ctx.destination)
  fetch('wasm/wasm_audioworklet_synth.wasm')
    .then(r => r.arrayBuffer())
    .then(r => n.port.postMessage({ type: 'loadWasm', data: r }))
}

processor.js

class MyProcessor extends AudioWorkletProcessor {
  constructor() {
    super()
    this.port.onmessage = e => {
      if (e.data.type === 'loadWasm') {
        WebAssembly.instantiate(e.data.data).then(w => {
          this._wasm = w.instance
          this._size = 128
          this._outPtr = this._wasm.exports.alloc(this._size)
          this._outBuf = new Float32Array(
            this._wasm.exports.memory.buffer,
            this._outPtr,
            this._size
          )
      }
    }
  }

  process(inputs, outputs, parameters) {
    if (!this._wasm) {
      return true
    }

    let output = outputs[0]
    for (let channel = 0; channel < output.length; ++channel) {
      let outputChannel = output[channel]
      this._wasm.exports.process(this._outPtr, this._size)
      outputChannel.set(this._outBuf)
    }

    return true
  }
}

registerProcessor('my-processor', MyProcessor)

実際のコードからは簡略化してますが、以下のような手順で WebAssembly を AudioWorklet の Processor 内で使うようにしています。

  1. AudioWorkletProcessor モジュールを追加
  2. 追加された Processor ノードを生成
  3. WASM バイナリを fetch
  4. WASM バイナリのバイト列を postMessage で Processor ノードに送信
  5. Processor ノードで WebAssembly のインスタンス生成

WebAssembly のインスタンス生成時に alloc したりポインタ設定したりしているのは、現状 JavaScript と WebAssembly 間で配列のやりとりは関数経由では直接行えず、共有のメモリ内に領域を確保し、そのポインタを受け渡すという実装になっています。
今回は関数とのやりとりに毎回決まったサイズの配列を受け渡しのためだけ (受けとった配列はすぐに別領域にコピーしている) なのでインスタンス生成時にその領域を確保して使い回します。

Rust によるシンセの実装

あまり複雑なことはしていなく、シンプルに 1 VCO (ノコギリ波のみ) / 1 VCF (ローパスフィルタ) / 1 VCA / 1 ENV なシンセサイザーです。

シンセサイザーは時間的な状態を持つことになるので、グローバルな領域に変数を保持する必要があり、今回はそれを lazy_static を使ってやっています。

lazy_static! {
    static ref SYNTH: Mutex<Synth> = Mutex::new(Synth::new());
}

#[no_mangle]
pub extern "C" fn process(out_ptr: *mut f32, size: usize) {
    let mut synth = SYNTH.lock().unwrap();
    synth.process(out_ptr, size);
}

また、JavaScript 側と受け渡すバッファの領域を作成しそのポインタを返す関数 alloc と、関数が呼ばれた時に配列にアクセスするところは以下です。
この辺りは色々参考にしていてもみな似たような実装行っているようでした。std::mem::forget はなるほどと思いました。

#[no_mangle]
pub extern "C" fn alloc(size: usize) -> *mut f32 {
    let mut buf = Vec::<f32>::with_capacity(size);
    let ptr = buf.as_mut_ptr();
    std::mem::forget(buf);
    ptr as *mut f32
}
...
    pub fn process(&mut self, out_ptr: *mut f32, size: usize) {
        let out_buf: &mut [f32] = unsafe { std::slice::from_raw_parts_mut(out_ptr, size) };
        ...
    }

他はシンセを実装すると大体こんな感じになるよねといった普通かつ対した実装にはなっていないので詳細は省略…
気になる方はこのあたりを見てみて下さい。現状妙なハードコードとかあってだいぶ雑でございますm(_ _)m

まとめ

  • Rust でシンセは作れる
  • ちょっとめんどくさいけど難しくはない
  • wasm-bindgen 使えるなら使った方が開発は楽しいと思う
  • Rust 力の足りなさ
  • 誰か TB-303 ください
44
18
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
44
18