Next.js+AudioWorklet+wasm(Rust)で音声処理サービスを作る

投稿日: 2021年 8月 9日

木瓜丸です。

只今音楽のサービスをNext.jsで作っていて、「音声処理にwasm使ったら速そうだし使いたいなー」と思って試してみたら案外大変だったので、備忘録をまとめたいと思います。

AudioWorkletってどんな?

AudioWorkletはダイナミックにAudioWorklet用のスクリプトをAudioContextに読み込ませて使います。

const audioContext = new AudioContext();
audioContext.audioWorklet.addModule('path/to/audioworklet').then(() => {
  const node = new AudioWorklet(audioContext, 'my-processor');
})

読み込ませるスクリプトの中ではAudioWorkletProcessorを実装し、これをregisterして使えるようにしています。

class MyProcessor extends AudioWorkletProcessor {
  process(inputs, outputs, params) {
    //なんかむずかしいしょり
    return true;
  }
}
registerProcessor('my-processor', MyProcessor)

ハマったポイント

ここで、AudioWorkletProcessor内でwasmを呼び出したいのですが、これがうまく行かないです。

class MyProcessor extends AudioWorkletProcessor {
  constructor(opts){
    super(opts);
    import('path/to/wasm').then(() => {
      //初期化処理
    })
  }
  process(inputs, outputs, params) {
    //なんかむずかしいしょり
    return true;
  }
}
registerProcessor('my-processor', MyProcessor)

Webpackを使ってこれとwasmをバンドルしてやろうと思っていたのですが、document is not definedというエラーが帰ってきてしまいました。

ブラウザでJavaScriptを読み込む場合はグローバルオブジェクトはwindowですが、読み込まれる側のスクリプトの場合はAudioWorkletGlobalScopeになるため、window.fetchやwindow.documentと言ったオブジェクトにアクセスできません。 ので、wasmを非同期に読み込むために.wasmファイルをfetchすると言ったことがAudioWorkletProcessor内部では出来ないみたいです。

Webpackしてあげるとこのダイナミックインポートがwasmのfetchに置き換えられたりするので色々止まってしまうっぽいです。

試したこと

MessagePort + Next.js

AudioWorklet+wasmみたいなキーワードで調べると結構出てきますが、MessagePortという機能を使ってAudioWorklet外部からwasmを渡してあげます。 なんかスマートじゃねえなと思いながら見ていたのですが、大人しくこれにしたいましょう(TT)

ただ、私の場合はNext.jsを使っているので、一旦Next.jsでwasmをimportし、同期的に関数を読み込んで渡してあげようと思いました。 Next.jsで読み込んであげた関数そのものをMessagePortから投げてあげます。

import { processor } from 'path/to/processor.wasm';

export default function MyComponent() {
  const audioContext = new AudioContext();
  audioContext.audioWorklet.addModule('/path/to/worklet').then(() => {
    const node = new AudioWorkletNode('my-processor');
    node.postMessage({
      type: 'loadWasm',
      processor
    });
  });
  return (
    <>
      hogehoge
    </>
  )
}

Processor側ではMessagePortから受け取った関数を使うようにセットしてあげます。

class MyProcessor extends AudioWorkletProcessor {
  constructor(opt) {
    super(opt);
    this.port.onmessage = e => {
      if(e.data.type == 'loadWasm') {
        this.processor = e.data.processor;
      }
    }
  }
  process(input, output, params) {
    if(this.processor){
      const input_ = input[0];
      for(let channel = 0; channel < input_.length; channel ++){
        const processed = this.processor(input_[channel]);
        for(let i = 0; i < output.length; i++){
          output[i].set(processed)
        }
      }
    }
    return true;
  }
}

これで一見落着!...と思ったのですが、MessagePortからは関数オブジェクトを渡すことは出来ないらしく、うまく行きませんでした。

じゃあfetchしてバイナリを食わせる

というわけで、MessagePortからwasmのバイナリを投げてみることにしました。

import { processor } from 'path/to/processor.wasm';

export default function MyComponent() {
  const audioContext = new AudioContext();
  audioContext.audioWorklet.addModule('/path/to/worklet').then(() => {
    const node = new AudioWorkletNode('my-processor');
    const bin = await fetch('/path/to/processor.wasm').then(res => res.arrayBuffer());
    node.postMessage({
      type: 'loadWasm',
      processor: bin
    });
  });
  return (
    <>
      hogehoge
    </>
  )
}
class MyProcessor extends AudioWorkletProcessor {
  constructor(opt) {
    super(opt);
    this.port.onmessage = e => {
      if(e.data.type == 'loadWasm'){
        WebAssembly.instantiate(e.data.processor, {}).then(w => {
          this.processor = w.instance
        })
      }
    }
  }
  process(input, output, params) {
    if(this.processor){
      const input_ = input[0];
      for(let channel = 0; channel < input_.length; channel ++){
        const processed = this.processor(input_[channel]);
        for(let i = 0; i < output.length; i++){
          output[i].set(processed)
        }
      }
    }
    return true;
  }
}

今度は読み込みはうまく行きました!しかし、AudioWorkletNodeをインスタンス化するとこんなエラーが出てきました。

TypeError: WebAssembly.instantiate(): Import #0 module="wbg" error: module is not an object or function

コードを確認した所、wasm-bindgenを使っていたせいで生成されたjsを見に行ってたらしく、そこで止まっていました。wasm-bindgenには頼れないらしい。

解決方法

というわけで、wasm-bindgenに頼らずに実装を行い、線形メモリをゴニョゴニョしながらRustで書いたものをjsに読ませたら行けました。

なんか、wasmもまだまだ大変だけど、何はともあれwasmへの夢を捨てきれないので食いついていこうかなと思っています。がんばろ...

© 2021 木瓜丸