Help us understand the problem. What is going on with this article?

Web Audio API + Faust : ブラウザベースで動くオーディオエフェクターを作る

More than 1 year has passed since last update.

この記事は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...

とりあえず,何かオーディオエフェクターらしきものでも作ってみます.文法やアルゴリズムの解説をはじめるとキリが無いので,興味のある方は公式チュートリアルがあるのでそちらにトライしてみてください.

DelayEffector.dsp
// 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).ギターなどやる人にとってはディレイといったほうがなじみ深いかもしれません.

キャプチャ.PNG
Fig 1. エフェクターのブロックダイヤグラム

これをスタンドアローンアプリにコンパイルしてみます.

compile2stand_alone_app
$ faust2alqt DelayEffector.dsp

capture_standalone_app.PNG

コマンド一発でいい感じのGUI付きのアプリができました.ホストのアクティブなオーディオデバイスも自動で認識してくれます.

ちなみに,公式で便利なオンラインエディター+コンパイラが提供されてるので,こちらを使ってもよいと思います.

Web Audio API + Faust

 今度は,先のコードをWebAssemblyバイナリにコンパイルして,Web Audio APIと組み合わせブラウザ上で動かしてみます.

compile2wasm
$ faust2wasm DelayEffector.dsp
# => Compiling with : -ftz 2
# => Compiled with 'wasm' backend
# => DelayEffector.js, DelayEffector.wasm;

WebAssemblyバイナリとそれを使うためのjsファイルが吐き出されました.

適当なwavファイルを読み込んで再生するワンショットサンプラー的なものを作って,その出力をエフェクターに流し込む感じのページを作っていきます.

index.jsx
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()でパラメータを更新できます.シンプルですね.

冒頭にも張りましたが,出来上がったものが以下になります(音が鳴るので注意).

WebAudioAPI+Faust_Demo

その他

 実際のところFaustで書かれたコードは一旦C++にトランスパイルされています.だから,wasmが出力できるんですね.また,冒頭にも述べたようにFaustのコンパイラは,コマンド一発で様々なフォーマットを吐き出すことができます."faust2firefox"コマンドなんかは,結構面白くて,システムの構造をブロック線図として出力することができます (Fig. 1のブロック線図はこの機能を使って出力したものになります).他にもVSTをコマンド一発で吐き出すこともできるので,いろいろ試してみたいですね.

wano-inc
「Cultivate your dream」をミッションにクリエイター・アーティストを支援する事業やサービスを展開
https://wano.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした