はじめに
この記事では、 WebAudio と Rust でボイスチェンジャーのエフェクトを作る方法について紹介します。
具体的には Rust のコードを WASM モジュールとしてビルドして JavaScript に組み込み、これを WebAudio API から呼び出すようにします。
ソースコード
サンプルサイト
実際に自分の声を使って試せるサンプルサイトを用意しました。
ナイーブな実装になっているため品質についてはあまり良くないですが、ブラウザ内で音声を加工できることは確認できるかと思います。
(意図せず大きな音が出てしまうかもしれないので、音量には注意してください。)
各パラメータの意味は以下のとおりです。
パラメータ | 意味 |
---|---|
Dry/Wet | オリジナルの音声と加工後の音声をどれくらいの割合で混ぜるか。右に行くほど加工した声の割合が多くなります |
Output Gain | 出力する音量 |
Pitch Shift | 音声の高さを変化させる度合い。右に行くほど音が高くなります |
Formant Shift | フォルマントを変化させる度合い。右に行くほど女性や幼い感じの声になります |
Envelope Order | 音声信号からフォルマントを抽出するときの細かさを変えるパラメータです。自分の声に合わせて適当なパラメータを探してください。 |
男性の低い声であっても、 Pitch Shift と Formant Shift を高めの値に設定することで(ある程度)女性のような声に加工できます。
使用している技術について
このプロジェクトは WebAudio API で入力した音声を WASM で加工し、それをまた WebAudio API 経由でオーディオデバイスに流しています。
このとき、 WASM のモジュールを Rust で実装することができるため、実際に Rust でボイスチェンジャーの信号処理のコードを実装しています。
WebAudio API とは
WebAudio API は Web アプリケーション内でオーディオを合成したり加工したりするための API 群です。
2021年6月に W3C 勧告となり、現在は多くのブラウザで利用できます。
https://www.w3.org/TR/webaudio/
https://www.g200kg.com/archives/2021/07/w3c-web-audio-a.html
WebAudio API は AudioContext というオブジェクトの中に、いくつかの Node と呼ばれるオブジェクトをグラフ状に繋いでオーディオ処理を行う仕組みになっています。
-- https://developer.mozilla.org/ja/docs/Web/API/Web_Audio_API
Node は入力用、エフェクト用、出力用の三種類が存在します。
ノード種別 | 機能 |
---|---|
入力ノード | マイク入力、<audio> タグの音声データの入力、矩形波/方形波などの波形合成など |
エフェクトノード | ディレイ、フィルター、リバーブなど |
出力ノード | スピーカーや WebRTC の AudioTrack への出力を行う |
具体的にどのようなノードが用意されているかは https://developer.mozilla.org/ja/docs/Web/API/Web_Audio_API に記載されています。
また WebAudio API については以下のサイトのような解説記事が色々とあるため、より詳しくはそれらをご参照ください。
AudioWorketNode と AudioWorketProcessor
WebAudio API には音を加工するための Node がいくつか定義されていますが、それら既存の Node では機能が足りないことがあります。そのような場合は、 AudioWorketNode という Node と、それに対応する AudioWorkletProcessor クラスを利用することでカスタムの信号処理を実装できます。
AudioContext の信号処理で、 AudioWorkletNode に対して信号が流れてくると、その Node に対応する AudioWorketProcessor の process() メソッドが呼び出されます。
プログラマは AudioWorketProcessor を継承したクラスを定義し、その process() メソッドにカスタムの信号処理を実装します。そして AudioWorketNode に紐づけておくことで、カスタムの信号処理が呼び出される流れになります。
// AudioWorkletProcessor を継承したカスタムの Processor を定義
class MyProcessor extends AudioWorkletProcessor {
constructor() {
super();
//...
}
// カスタムエフェクト処理のための関数。
// WebAudio API から自動で呼び出される
process(input, output, parameter) {
// input に含まれているオーディオデータを加工して
// output に書き込む
}
}
// このクラスを Processor という名前で登録
registerProcessor("Processor", MyProcessor);
class ProcessorNode extends AudioWorkletNode {
// ...
};
async function setupAudioProcess()
{
let audioContext = getAudioContextFromSomewhere();
// MyProcessor を定義しているファイルを AudioContext 内に読み込み
const processorUrl = "Processor.js";
try {
await audioContext.audioWorklet.addModule(processorUrl);
} catch (e) {
let err = e as unknown as Error;
throw new Error(
`Failed to load audio analyzer worklet at url: ${processorUrl}. Further info: ${err.message}`
);
}
// 登録した名前の Processor と紐付ける形で AudioWorkletNode を構築
let node = new ProcessorNode(audioContext, "Processor");
// 構築したノードは別のノードに接続できる
node.connect(audioContext.destination);
}
AudioWorketProcessor の処理は UI 処理用のスレッドとは別の専用のスレッドで実行されます。
そのため、 UI の処理から直接 AudioWorketProcessor の関数を呼び出したり、逆に AudioWorketProcessor から UI の関数を呼び出したりはできません。
UI と AudioWorketProcessor で相互に情報をやり取りするには、 AudioWorketNode と AudioWorketProcessor にそれぞれ用意されている MessagePort オブジェクトを使ってお互いにメッセージを送るようにします。
class MyProcessor extends AudioWorkletProcessor {
// Node 側から来たメッセージを受け取り処理を行う
onmessage(event) {
if (event.type === "my-event-type1") {
doSomething(event.data);
} else if(event.type === "my-event-type2") {
doSomething(event.data);
}
}
process(input, output) {
let shouldPostMessage = doSomething(input, output);
if(shouldPostMessage) {
// Processor 側から Node 側にメッセージを送る
this.port.postMessage({ type: "my-event-type3", data: "..." });
}
}
}
class ProcessorNode extends AudioWorkletNode {
// Processor 側から来たメッセージを受け取り処理を行う
onmessage(event) {
if (event.type === "my-event-type3") {
doSomething(event.data);
}
}
myFunction1(type, value) {
// Node 側から Processor 側にメッセージを送る
this.port.postMessage({ type, data: value });
}
AudioWorkletProcessor からの Rust のコード呼び出し
AudioWorkletProcessor と WASM
AudioWorkletProcessor によるカスタムの信号処理は JavaScript で実装できるようになっています。ということは JavaScript から WASM モジュールを呼び出してそっちで信号処理を行うことも可能です。
WASM を使用すれば、すべてを JavaScript で実装するよりもネイティブに近いパフォーマンスで処理を行えます。
WASM モジュールは様々な言語からビルドできるようになっていて、 Rust もこの仕組みをサポートしています。
Rust で WASM モジュールもビルドする方法
Rust で WASM モジュールを使って AudioWorkletProcessor と組み合わせる方法はこのサイトが丁寧でわかりやすいので参考になります。
具体的には次のようにします。
- rust の wasm-bindgen crate を使って Rust の構造体や関数を JavaScript から利用できるようにする。
- さらに Rust のプロジェクトを実際に Wasm モジュールとしてビルドするために wasm-pack を使ってビルドを行う。
wasm-pack でビルドを行うと .wasm ファイルを含むいくつかのファイルが生成されます。
AudioWorkletProcessor で WASM モジュールを読み込むには、これらのファイルをフロントエンドで呼び出せる場所に配置しておく必要があります。
WASM モジュールの読み込み
AudioWorkletProcessor で WASM モジュールを使用する際、 fetch() 関数などを呼び出して直接 WASM モジュールを取得することはできません。これは前述の通り、 AudioWorkletProcessor が専用の AudioWorklet スレッドで動作しているためです。
このため、 AudioWorkletProcessor で WASM モジュールを使用するには、 AudioWorkletNode からバイナリ列として WASM モジュールを渡す必要があります。
class MyProcessor extends AudioWorkletProcessor {
onmessage(event) {
// Node 側から WASM モジュールのバイナリ列を受け取ったら、それをコンパイルして使用可能にする。
// コンパイルが完了したその通知メッセージを投げる。
if (event.type === "send-wasm-module") {
init(WebAssembly.compile(event.wasmBytes)).then(() => {
this.port.postMessage({ type: 'wasm-module-loaded' });
});
}
}
process(input, output) {
// ...
}
}
class ProcessorNode extends AudioWorkletNode {
init(wasmBytes) {
this.port.postMessage({
type: "send-wasm-module",
wasmBytes,
});
}
onmessage(event) {
if (event.type === "wasm-module-loaded") {
// ...
}
}
};
async function setupAudioProcess()
{
// Fetch the WebAssembly module that performs processing audio.
const response = await window.fetch("wasm-audio/wasm_audio_bg.wasm");
const wasmBytes = await response.arrayBuffer();
// ...
// AudioWorkletNode を構築
const node = new ProcessorNode(audioContext, "Processor");
// node に WASM モジュールのバイト列を渡して初期化
node.init(wasmBytes);
}
Rust 側の処理とオーディオデータの受け渡し
Rust 側で信号処理を行うためのコードは以下のようになります。
#[wasm_bindgen]
pub struct WasmProcessor {
sample_rate: usize,
block_size: usize,
fft: Arc<dyn Fft<f32>>,
//...
}
#[wasm_bindgen]
impl WasmProcessor {
pub fn new(sample_rate: usize, block_size: usize) -> WasmProcessor {
// ...
}
// buffer で受け取った信号を加工して上書きすることで、音声を変化させる。
// levels は長さ2の配列になっていて、信号処理する前と後の音量をそれぞれ書き込んで
// JavaScript 側に返す。
pub fn process(&mut self, buffer: &mut [f32], length: usize, levels: &mut [f32]) {
// ...
}
上記のスニペットにあるように、 #[wasm_bindgen]
属性を付けたクラスや関数が JavaScript から利用できるようになります。
このクラスを AudioWorkletProcessor から呼び出すのは、単に JavaScript のクラスを呼び出すのと同じように書けます。
import "./TextEncoder.js";
import init, { WasmProcessor } from "./wasm-audio/wasm_audio.js";
class Processor extends AudioWorkletProcessor {
onmessage(event) {
// Node 側から初期化処理のメッセージが飛んできたとき
if (event.type === 'init-processor') {
const { sampleRate, blockSize } = event;
// Store this because we use it later to process when we have enough recorded
// audio samples for our first analysis.
this.blockSize = blockSize;
// Rust 側で定義した WasmProcessor クラスをインスタンス化する
this.processor = WasmProcessor.new(sampleRate, blockSize);
}
process(inputs, outputs) {
if(!this.processor) {
return true;
}
// バッファの準備など
// WasmProcessor の process() 関数の呼び出し
this.processor.process(this.processBuffer, len, this.levels);
// levels にレベルメータ用の音量データが書き込まれるのでそれを UI 側に返す。
this.port.postMessage({
type: "update-levels",
data: {
inputLevel: this.levels[0],
outputLevel: this.levels[1],
}
});
// process() 関数で処理されたオーディオデータを output に書き込む。
// これによって下流にオーディオデータが流れる。
for (let i = 0; i < len; ++i) {
const sample = this.processBuffer[i];
output[0][0][i] = sample;
output[0][1][i] = sample;
}
return true;
}
}
ボイスチェンジャー処理の概要
ボイスチェンジャーの実際の信号処理については、概要だけ解説します。
- 入力した音声信号を STFT する -- (A)
- STFT した信号を対数振幅スペクトルにしてから ISTFT して、ケプストラムを取得する。
- ケプストラムに対して低次の信号だけを取り出し、それを FFT することでスペクトル包絡を抽出する。 -- (B)
- (B) に対して Formant Shift パラメータをもとにスペクトル包絡の伸縮処理を行う。 -- (C)
- (A) の信号に対して Pitch Shift パラメータをもとにフェースボコーダー処理を行ってピッチシフトする。
- ピッチシフトした信号に対して (C) のスペクトル包絡を適用する
この実装については https://shop.cqpub.co.jp/detail/2539/ を参考に実装しました。
まとめ
今回はブラウザ上で音声を加工する処理を Rust で書く技術について紹介しました。
株式会社LabBase では音声信号処理はやっていないですが Rust や TypeScript はいっぱい使っています!
これらの言語に興味がある人はぜひ LabBase へ!