この記事はWanoグループ Advent Calendar 2018の17日目の記事です(定型文).
Web上で音を取り扱うためのJavaScript APIであるWeb Audio APIとFaustというプログラミング言語を組み合わせて,ブラウザ上で動作するエフェクターを作ってみた話です.
Web Audio API
Web Audio APIは,ブラウザ上でただ音を再生したりするだけでなく,オシレータやフィルタといった基本的なDSP (Digital signal processing)モジュールも一通り揃っており,これらを組み合わせるだけで結構本格的なオーディオアプリが作れるので楽しいですよね.JSなのでボリュームノブ等のGUIも簡単に設置できるのも魅力的です.
Faust
Faust(Functional Audio Stream) Languageは,フランスのGrame研究所が開発したその名の通りオーディオプログラミングに特化した関数型言語です.このFaustの面白いところは,Faustで書かれた単一のソースから,スタンドアローンアプリ,WebAssemblyバイナリ,DAW上で動作するVSTプラグインなど様々なフォーマットへコンパイルすることができる点です.
Faust
├── wasm
├── stand_alone_app
├── vst
└── etc...
とりあえず,何かオーディオエフェクターらしきものでも作ってみます.文法やアルゴリズムの解説をはじめるとキリが無いので,興味のある方は公式チュートリアルがあるのでそちらにトライしてみてください.
// javascriptのSyntax hiliteで誤魔化す(割といい感じなのでは?)
import("stdfaust.lib");
DelayG(x) = hgroup("Delay", x);
OtherG(x) = hgroup("SubEffector", x);
// control params
params = environment {
delayTimeMsec = DelayG(hslider("Delay Time (msec)[style:knob]", 100, 1, 1000, 1));
feedbackGain = DelayG(hslider("Delay Feedback[style:knob]", 0.5, 0.0, 1.0, 0.01));
cutoff = OtherG(hslider("Cutoff Frequency (Hz)[style:knob]", 5000, 40, 15000, 1));
driveGain = OtherG(hslider("Drive[style:knob]", 5.0, 1.0, 20.0, 0.01));
wetness = DelayG(hslider("Wet[style:knob]", 0.5, 0.0, 1.0, 0.01));
};
// with liner interpolation
delay(u) = @(u, int(tau) + 1) * c, @(u, int(tau)) * (1.0 - c) : +
with {
tau = (params.delayTimeMsec * 10^(-3)) * ma.SR; // delay block length
c = ma.frac(tau);
};
// simple RC lowpass filter
lowpassFilter(u) = b0*u + b1*@(u, 1) : - ~*(a1)
with {
w = 2.0*ma.PI*params.cutoff;
// filter coefficients
b0 = w / (w + 2.0*ma.SR);
b1 = b0;
a1 = (w - 2.0*ma.SR) / (w + 2.0*ma.SR);
};
distortion(u) = atan(g*u) / (0.5*ma.PI)
with {
g = params.driveGain;
};
mixer(drySig, wetSig) = (1.0 - k) * drySig, k * wetSig : +
with {
k = params.wetness;
};
feedbackDelay(u) = delay~(distortion : lowpassFilter : *(g), u : +)
with {
g = params.feedbackGain;
};
monoProcess = _ <: _, feedbackDelay : mixer;
// main process
process = monoProcess, monoProcess;
今回書いたのは,入力音を一定の間隔でエコーさせるものです(Fig. 1).ギターなどやる人にとってはディレイといったほうがなじみ深いかもしれません.
これをスタンドアローンアプリにコンパイルしてみます.
$ faust2alqt DelayEffector.dsp
コマンド一発でいい感じのGUI付きのアプリができました.ホストのアクティブなオーディオデバイスも自動で認識してくれます.
ちなみに,公式で便利なオンラインエディター+コンパイラが提供されてるので,こちらを使ってもよいと思います.
Web Audio API + Faust
今度は,先のコードをWebAssemblyバイナリにコンパイルして,Web Audio APIと組み合わせブラウザ上で動かしてみます.
$ faust2wasm DelayEffector.dsp
# => Compiling with : -ftz 2
# => Compiled with 'wasm' backend
# => DelayEffector.js, DelayEffector.wasm;
WebAssemblyバイナリとそれを使うためのjsファイルが吐き出されました.
適当なwavファイルを読み込んで再生するワンショットサンプラー的なものを作って,その出力をエフェクターに流し込む感じのページを作っていきます.
import React from 'react';
import {render} from 'react-dom';
import {faust} from './DelayEffector.js';
class DelayEffector extends React.Component {
constructor() {
super();
this.state = {
audioCtx: null,
audioData: null,
samplerNode: null,
delayNode: null,
effectBypass: false,
delayTimeMsec: 100,
feedbackGain: 0.3,
drive: 1.0,
cutoffFreqHz: 1000,
wetness: 0.5,
}
this.paramName2faustAdressName = {
delayTimeMsec: "/DelayEffector/Delay/Delay_Time_(msec)",
feedbackGain: "/DelayEffector/Delay/Delay_Feedback",
wetness: "/DelayEffector/Delay/Wet",
drive: "/DelayEffector/SubEffector/Drive",
cutoffFreqHz: "/DelayEffector/SubEffector/Cutoff_Frequency_(Hz)",
}
}
componentDidMount() {
const audioCtx = new AudioContext();
this._loadAudioFile(audioCtx, './SampleLoop.wav');
this._setUpRooting(audioCtx);
this.setState({
audioCtx: audioCtx
});
}
_loadAudioFile(audioCtx, path) {
const req = new XMLHttpRequest();
req.open("GET", path, true);
req.responseType = "arraybuffer";
req.onload = () => {
if(!req.response) return;
audioCtx.decodeAudioData(req.response).then(decodeBuff => this.setState({audioData: decodeBuff}))
};
req.send();
}
_setUpRooting(audioCtx) {
const samplerNode = audioCtx.createBufferSource();
faust.createDelayEffector(audioCtx, 1024, (delayNode) => {
console.log(delayNode.getParams());
samplerNode.connect(delayNode);
delayNode.connect(audioCtx.destination);
this.setState({delayNode: delayNode});
})
this.setState({samplerNode: samplerNode});
}
playAudio() {
const samplerNode = this.state.samplerNode;
samplerNode.buffer = this.state.audioData;
samplerNode.loop = true;
samplerNode.start();
}
onChangeBypass(ev) {
const effectBypass = ev.target.checked;
const val = effectBypass ? 0 : this.state.wetness;
const delayNode = this.state.delayNode;
delayNode.setParamValue(this.paramName2faustAdressName["wetness"], parseFloat(val));
this.setState({ effectBypass: effectBypass });
}
onChangeWetness(ev) {
const val = ev.target.value;
const effectBypass = this.state.effectBypass;
if (!effectBypass) {
const delayNode = this.state.delayNode;
delayNode.setParamValue(this.paramName2faustAdressName["wetness"], parseFloat(val));
}
this.setState({ wetness: val });
}
onChangeSlider(paramName) {
const delayNode = this.state.delayNode;
return (ev) => {
const val = ev.target.value;
const adress = this.paramName2faustAdressName[paramName];
delayNode.setParamValue(adress, parseFloat(val));
this.setState({ [paramName]: val });
}
}
render () {
const { delayTimeMsec, feedbackGain, wetness, drive, cutoffFreqHz } = this.state;
return (
<div>
<h1>Sampler</h1>
<button onClick={this.playAudio.bind(this)}>Play Audio</button>
<h1>Effector</h1>
<input type="checkbox" onChange={this.onChangeBypass.bind(this)} />Effect Bypass <br />
<p>Delay Time (msec)</p>
<input type="range" value={delayTimeMsec} min="100" max="1000" step="1" onChange={this.onChangeSlider("delayTimeMsec").bind(this)} />
<span>{delayTimeMsec} msec</span>
<p>Feedback Gain</p>
<input type="range" value={feedbackGain} min="0" max="1" step="0.01" onChange={this.onChangeSlider("feedbackGain").bind(this)} />
<span>{feedbackGain}</span>
<p>Feedback Distortion</p>
<input type="range" value={drive} min="1" max="20" step="0.1" onChange={this.onChangeSlider("drive").bind(this)} />
<span>{drive}</span>
<p>LPF Cutoff Frequency (Hz)</p>
<input type="range" value={cutoffFreqHz} min="40" max="10000" step="1" onChange={this.onChangeSlider("cutoffFreqHz").bind(this)} />
<span>{cutoffFreqHz} Hz</span>
<p>Wetness</p>
<input type="range" value={wetness} min="0" max="1" step="0.01" onChange={this.onChangeWetness.bind(this)} />
<span>{wetness * 100.0} %</span>
</div>
)
}
}
render(<DelayEffector />, document.getElementById('app'));
wasmを吐き出すときにできたDelayEffector.jsに記述されているfaustインスタンスから,Web Audioで使えるAudioNodeインスタンスが作れます.後は,node.setParamValue()でパラメータを更新できます.シンプルですね.
冒頭にも張りましたが,出来上がったものが以下になります(音が鳴るので注意).
その他
実際のところFaustで書かれたコードは一旦C++にトランスパイルされています.だから,wasmが出力できるんですね.また,冒頭にも述べたようにFaustのコンパイラは,コマンド一発で様々なフォーマットを吐き出すことができます."faust2firefox"コマンドなんかは,結構面白くて,システムの構造をブロック線図として出力することができます (Fig. 1のブロック線図はこの機能を使って出力したものになります).他にもVSTをコマンド一発で吐き出すこともできるので,いろいろ試してみたいですね.