Web Audio APIで波形表示とオーディオエフェクトを実装する

UI開発者 泉口

Web Audio APIとは、2011年にAudio Working Groupによって定義されたWebアプリケーションにおけるオーディオ処理・合成を行うJavaScript APIです。機能概要としては次の項目が挙げられます。

  • sine、square、sawtooth、triangleなどのオシレータ(発振)を実装できる
  • フィルター、ディストーション、コンプレッサーなどの基本的なエフェクトインターフェースが用意されている
  • Flash、QuickTimeなどのプラグインは不要
  • 複数のInput、Output構成におけるミキシングが可能
  • JavaScriptソースコード上でのモジュラールーティングが可能
  • 5.1chなどのサラウンドシステムの実装も可能

アナログシンセサイザーを扱ったことのある方や、デスクトップミュージック(DTM)を嗜んだことがある方であれば、上記の簡単な概要だけでも、Web Audio APIでできること、その可能性は容易に想像がつくと思われます。現に、Moog Synthesizerなどの名器をシミュレートしたブラウザ上で発音するソフトウェアシンセサイザーや、ドラッグ&ドロップで行う直感的なケーブルルーティング、ブラウザ上のDAW・シーケンサーなど、Web Audio APIの機能をフル活用したWebアプリケーションはすでに存在しています。

Web Audio APIの詳細は下記の仕様書をご確認ください。

今回はこのWeb Audio APIを使って、input[type="file"]要素へ読み込んだ音声ファイルの波形表示とコンプレッサー、ディストーション、イコライザーのオーディオエフェクトを実装してみたいと思います。準備するものはGoogle Chrome最新版(2017年1月現在のバージョン Stable 55.0.2883.87)、XMLHttpRequestが使用できる環境のみです。

HTML

HTMLコードでは、section要素ごとに、読み込みと再生を行うコントローラ、波形を表示するcanvas要素、各オーディオエフェクトのコントローラを配置しています。今回はテストを兼ねてインターフェースごとのプロパティをinput[type="range"]で記述していますが、実際のWebアプリケーションを作成する際は各インターフェースに規定値、最大値、最小値が読み取り専用プロパティとして存在しているため、動的にビューとなる要素を生成することも可能です。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Document</title>
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/normalize/5.0.0/normalize.css">
<link rel="stylesheet" href="//fonts.googleapis.com/css?family=Roboto+Condensed|Material+Icons">
<link rel="stylesheet" href="common.css">
</head>
<body>

<div class="case">

<section class="stack">
<h2>Control</h2>
<div class="inner">
<div id="control">
<button id="btn-play" class="material-icons">play_arrow</button>
<button id="btn-stop" class="material-icons">stop</button>
<input type="file" id="audio-input" value="">
<button id="btn-clear" class="material-icons">clear</button>
</div>
</div>
</section>

<section class="stack">
<h2>Sound</h2>
<div class="inner">
<canvas id="audio-visual" width="1280" height="100"></canvas>
<audio id="audio-player" loop></audio>
</div>
</section>

<section class="stack">
<h2>Output</h2>
<div class="inner params">
<label><span>Mute</span><input id="ctrl-mute" type="checkbox"></label>
<label><span>Gain</span><input type="range" id="ctrl-gain" min="0" max="3" step="0.1" value="1"><span class="param">1</span></label>
</div>
</section>

<section class="stack">
<h2>Compressor</h2>
<div class="inner params">
<label><span>ON</span><input id="ctrl-comp" type="checkbox"></label>
<label><span>Threshold</span><input type="range" id="ctrl-comp-thr" min="-100" max="0" value="-24"><span class="param">-24</span></label>
<label><span>Knee</span><input type="range" id="ctrl-comp-kne" min="0" max="40" value="30"><span class="param">30</span></label>
<label><span>Ratio</span><input type="range" id="ctrl-comp-rat" min="1" max="20" value="12"><span class="param">12</span></label>
<label><span>Attack</span><input type="range" id="ctrl-comp-atk" min="0" max="1" step="0.01" value="0.003"><span class="param">0.003</span></label>
<label><span>Release</span><input type="range" id="ctrl-comp-rel" min="0" max="1" step="0.01" value="0.25"><span class="param">0.25</span></label>
</div>
</section>

<section class="stack">
<h2>Distortion</h2>
<div class="inner params">
<label><span>ON</span><input id="ctrl-dist" type="checkbox"></label>
<label><span>Curve</span><input type="range" id="ctrl-dist-curve" min="0" max="400" step="1" value="200"><span class="param">200</span></label>
</div>
</section>

<section class="stack">
<h2>Equalizer</h2>
<div class="inner params">
<label><span>ON</span><input id="ctrl-eq" type="checkbox"></label>
<label><span>64Hz</span><input type="range" id="ctrl-eq-64" min="-50" max="50" step="1" value="0"><span class="param">30</span></label>
<label><span>256Hz</span><input type="range" id="ctrl-eq-256" min="-50" max="50" step="1" value="0"><span class="param">30</span></label>
<label><span>1024Hz</span><input type="range" id="ctrl-eq-1024" min="-50" max="50" step="1" value="0"><span class="param">30</span></label>
<label><span>2048Hz</span><input type="range" id="ctrl-eq-2048" min="-50" max="50" step="1" value="0"><span class="param">30</span></label>
<label><span>8192Hz</span><input type="range" id="ctrl-eq-8192" min="-50" max="50" step="1" value="0"><span class="param">30</span></label>
</div>
</section>
</div>

<script src="effect.js"></script>
</body>
</html>

JavaScript(effect.js)

本コードにおいて主に行っていることは次の通りです。

  • input[type="file"]要素のvalue値変更後、audio要素に反映し、XMLHttpRequestで取得したaudioBufferからサンプリングレートに応じて波形を生成、canvas要素に反映する
  • new AudioContextを起点にコンプレッサー、ディストーション、イコライザーを生成し、HTML要素の状態に応じてON/OFFを切り替える

波形の生成に関してはaudioBufferから生成しているだけなので、詳しい内容は割愛しますが、今回の波形全体像を表示する方法の他にも、AnalyserNodeインターフェースを用いることでリアルタイムで波形表示を行うことも可能です。

audio要素からcreateMediaElementSourceによって生成されたAudioNodeをconnectメソッドによって各インターフェースに接続します。この時、インターフェースの未使用時を想定したルーティング用の各ゲインを介すことで、次のインターフェースおよび、最終出力先であるdestinationへ接続しています。

ディストーション(WaveShaperNode)のcurve値の生成に関してはMDN AudioContext.createWaveShaper()を引用していますが、元となるソースコードはStack Overflow Kevin Ennisによる解答となります。

コードのほとんどはコントローラとビューに関する記述です。多少ややこしく感じるのはイコライザー関連のルーティング周りですが、createBiquadFilterではfrequency、Q、type(ピーキングだけでなく、ロー/ハイパス、ロー/ハイシェルフ、ノッチ/バンドパス)の値を変更することも可能なので、あえて今回のようなグラフィックイコライザーを定義せずとも、パラメトリックイコライザーを実装し、ルーティング周りを簡略化することも可能です。

(function (AudioContext) {
    'use strict';

    var actx = new AudioContext();
    var $audioPlayer = document.getElementById('audio-player');
    var $audioInput = document.getElementById('audio-input');
    var $audioPlay = document.getElementById('btn-play');
    var $audioStop = document.getElementById('btn-stop');
    var $audioClear = document.getElementById('btn-clear');
    var canvas = document.getElementsByTagName('canvas')[0];
    var canvasWidth = canvas.width;
    var canvasHeight = canvas.height;
    var canvasCtx = canvas.getContext('2d');

    $audioInput.addEventListener('change', function (event) {
        if (!event.target.files[0]) {
            $audioClear.click();
            return;
        }

        $audioPlayer.src = window.URL.createObjectURL(event.target.files[0]);
        createWaveform($audioPlayer.src);
    }, false);
    $audioPlay.addEventListener('click', function () {
        if ($audioPlayer.src) {
            $audioPlayer.play();
        }
    }, false);
    $audioStop.addEventListener('click', function () {
        $audioPlayer.pause();
        $audioPlayer.currentTime = 0;
    }, false);
    $audioClear.addEventListener('click', function () {
        $audioPlayer.pause();
        $audioPlayer.removeAttribute('src');
        $audioInput.value = '';
        clearCanvas();
    }, false);

    if ($audioPlayer.src) {
        createWaveform($audioPlayer.src);
    }

    function clearCanvas() {
        canvasWidth = canvas.width;
        canvasHeight = canvas.height;
        canvasCtx.clearRect(0, 0, canvasWidth, canvasHeight);
        canvasCtx.beginPath();
    }

    function getAudioBuffer(url, func) {
        var xhr = new XMLHttpRequest();

        xhr.responseType = 'arraybuffer';
        xhr.onreadystatechange = function () {
            if (xhr.readyState !== 4) {
                return;
            }

            actx.decodeAudioData(xhr.response, function (audioBuffer) {
                func(audioBuffer);
            });
        };

        xhr.open('GET', url, true);
        xhr.send();
    }

    function createWaveform(url) {
        clearCanvas();

        getAudioBuffer(url, function (audioBuffer) {
            var bufferFl32 = new Float32Array(audioBuffer.length);
            var msec = Math.floor(1 * Math.pow(10, -3) * actx.sampleRate);
            var leng = bufferFl32.length;

            bufferFl32.set(audioBuffer.getChannelData(0));

            for (var idx = 0; idx < leng; idx++) {
                if (idx % msec === 0) {
                    var x = canvasWidth * (idx / leng);
                    var y = (1 - bufferFl32[idx]) / 2 * canvasHeight;

                    if (idx === 0) {
                        canvasCtx.moveTo(x, y);
                    } else {
                        canvasCtx.lineTo(x, y);
                    }
                }
            }

            canvasCtx.strokeStyle = '#6cc7ff';
            canvasCtx.stroke();
        });
    }

    var source = actx.createMediaElementSource($audioPlayer);
    var gain = actx.createGain();
    var comp = actx.createDynamicsCompressor();
    var compGain = actx.createGain();
    var dist = actx.createWaveShaper();
    var distGain = actx.createGain();
    var eqGain = actx.createGain();
    var eqFreqs = [64, 256, 1024, 2048, 8192];
    var eqs = [];

    for (var i = 0; i < eqFreqs.length; i++) {
        var bqFilter = actx.createBiquadFilter();
        bqFilter.frequency.value = eqFreqs[i];
        bqFilter.Q.value = 1;
        bqFilter.type = 'peaking';
        bqFilter.gain.value = 0;
        eqs[i] = bqFilter;
    }

    gain.gain.value = 1;
    dist.curve = makeDistortionCurve(200);
    dist.oversample  = '4x';

    source.connect(gain);
    gain.connect(compGain);
    compGain.connect(distGain);
    distGain.connect(eqGain);
    eqGain.connect(actx.destination);

    function addEvent(elm, type, func) {
        document.getElementById(elm).addEventListener(type, function () {
            func(this);
        }, false);
    }
    /**
     * @see https://developer.mozilla.org/ja/docs/Web/API/AudioContext/createWaveShaper#Example
     */
    function makeDistortionCurve(amount) {
        var k = typeof amount === 'number' ? amount : 50;
        var nSamples = 44100;
        var curve = new Float32Array(nSamples);
        var deg = Math.PI / 180;
        var x;
        for (var i = 0; i < nSamples; ++i) {
            x = i * 2 / nSamples - 1;
            curve[i] = (3 + k) * x * 20 * deg / (Math.PI + k * Math.abs(x));
        }
        return curve;
    }

    addEvent('ctrl-mute', 'change', function (self) {
        if (self.checked) {
            $audioPlayer.muted = true;
        } else {
            $audioPlayer.muted = false;
        }
    });
    addEvent('ctrl-gain', 'input', function (self) {
        gain.gain.value = self.value;
        self.nextSibling.innerText = self.value;
    });
    addEvent('ctrl-comp', 'change', function (self) {
        if (self.checked) {
            gain.disconnect();
            gain.connect(comp);
            comp.connect(compGain);
        } else {
            gain.disconnect();
            comp.disconnect();
            gain.connect(compGain);
        }
    });
    addEvent('ctrl-comp-thr', 'input', function (self) {
        comp.threshold.value = self.value;
        self.nextSibling.innerText = self.value;
    });
    addEvent('ctrl-comp-kne', 'input', function (self) {
        comp.knee.value = self.value;
        self.nextSibling.innerText = self.value;
    });
    addEvent('ctrl-comp-rat', 'input', function (self) {
        comp.ratio.value = self.value;
        self.nextSibling.innerText = self.value;
    });
    addEvent('ctrl-comp-atk', 'input', function (self) {
        comp.attack.value = self.value;
        self.nextSibling.innerText = self.value;
    });
    addEvent('ctrl-comp-rel', 'input', function (self) {
        comp.release.value = self.value;
        self.nextSibling.innerText = self.value;
    });
    addEvent('ctrl-dist', 'change', function (self) {
        if (self.checked) {
            compGain.disconnect();
            compGain.connect(dist);
            dist.connect(distGain);
        } else {
            compGain.disconnect();
            dist.disconnect();
            compGain.connect(distGain);
        }
    });
    addEvent('ctrl-dist-curve', 'input', function (self) {
        dist.curve = makeDistortionCurve(parseInt(self.value, 10));
        self.nextSibling.innerText = self.value;
    });
    addEvent('ctrl-eq', 'change', function (self) {
        if (self.checked) {
            distGain.disconnect();
            distGain.connect(eqs[0]);
            eqs[0].connect(eqs[1]);
            eqs[1].connect(eqs[2]);
            eqs[2].connect(eqs[3]);
            eqs[3].connect(eqs[4]);
            eqs[4].connect(eqGain);
        } else {
            distGain.disconnect();
            eqs[0].disconnect();
            eqs[1].disconnect();
            eqs[2].disconnect();
            eqs[3].disconnect();
            eqs[4].disconnect();
            distGain.connect(eqGain);
        }
    });

    for (var _n = 0; _n < eqs.length; _n++) {
        (function (n) {
            addEvent('ctrl-eq-' + eqFreqs[n], 'input', function (self) {
                eqs[n].gain.value = self.value;
                self.nextSibling.innerText = self.value;
            });
        }(_n));
    }
}(window.AudioContext));

CSS(common.css)

ビジュアル的なオマケです。無くても機能します。

*,*::before,*::after{box-sizing:border-box}body,input,select,button{font-family:Roboto,Meiryo}body::before,body::after,body:after,.case::before,.stack::after,.stack::before,.stack h2::before,.stack h2::after,.stack label:before,.stack label input[type="checkbox"]::after{position:absolute;display:block;content:""}body{font-size:14px;background:#111;margin:0;padding:0;perspective:140px}body::before,body::after{z-index:1;left:0;width:100%;height:200px}body::before{background:linear-gradient(to bottom, #111 0%, #555 100%);top:0;transform:rotateX(50deg)}body:after{background:linear-gradient(to bottom, #555 0%, #111 100%);bottom:0;transform:rotateX(-50deg)}.case{background:#000;position:relative;z-index:2;width:548px;margin:48px auto;padding:6px 0 0;border:6px ridge #c7c7c7;border-radius:3px;box-shadow:0 1px 7px rgba(0,0,0,0.4)}.case::before{top:0;left:0;box-sizing:border-box;width:100%;height:100%;border:25px solid #565656;box-shadow:0 1px 4px #000}.case>:last-child{margin:1px}.stack{background:radial-gradient(#000000 15%,rgba(0,0,0,0) 16%) 0 0,radial-gradient(#000000 15%,rgba(0,0,0,0) 16%) 4px 4px,radial-gradient(rgba(255,255,255,0.1) 15%,rgba(0,0,0,0) 20%) 0 1px,radial-gradient(rgba(255,255,255,0.1) 15%,rgba(0,0,0,0) 20%) 4px 5px;background-color:#282828;background-size:8px 8px;position:relative;margin:0 2px 5px;padding:1px;border-top:1px solid #2b2b2b;border-bottom:1px solid #0e0e0e;border-radius:2px;box-shadow:inset 0 4px 3px rgba(0,0,0,0.5)}.stack::after{z-index:1;top:-5px;left:20px;width:calc(100% - 40px);height:0;border-right:15px solid transparent;border-bottom:5px solid #333;border-left:15px solid transparent}.stack::before{background:linear-gradient(to right, rgba(149,149,149,0.2) 0%, rgba(13,13,13,0.15) 46%, rgba(1,1,1,0.15) 50%, rgba(10,10,10,0.15) 53%, rgba(78,78,78,0.13) 76%, rgba(56,56,56,0.1) 100%);position:absolute;z-index:1;top:0;left:0;width:100%;height:100%}.stack h2{font-size:16px;color:#0e0f13;background-image:radial-gradient(ellipse farthest-corner at left top, #363636 1.1%,#535353 62.6%,#363636 100%);position:relative;z-index:2;margin:0;padding:10px 32px;border-top:1px solid #545454;box-shadow:0 0 5px rgba(0,0,0,0.3),0 -1px 2px rgba(255,255,255,0.1);text-shadow:0 1px 1px rgba(255,255,255,0.4),0 -1px 0 rgba(255,255,255,0.1)}.stack h2::before,.stack h2::after{background:#666;top:50%;width:6px;height:6px;margin:-3px 0 0;transform:rotate(-45deg);border-radius:50%;box-shadow:0 1px 0 rgba(255,255,255,0.5),inset 0 1px 0 rgba(255,255,255,0.5);opacity:.8}.stack h2::before{left:10px}.stack h2::after{right:10px}.stack .inner{position:relative;z-index:2;padding:10px}.stack .inner.params{display:flex;flex-wrap:wrap;margin:-8px 0 0 -16px}.stack label{position:relative;display:flex;flex-basis:50%;align-items:center;margin:8px 0 0;padding:4px 0 4px 16px}.stack label>*{position:relative;z-index:2}.stack label:before{background:rgba(0,0,0,0.65);z-index:1;top:0;right:0;width:calc(100% - 16px);height:100%;border:1px inset #2f2f2f;box-shadow:-1px 0 3px rgba(0,0,0,0.8)}.stack label span{text-align:right;color:#6cc7ff;margin:0 8px 0 0}.stack label span:first-child{font-size:12px;width:62px}.stack label span.param{font-size:10px;text-align:center;width:32px;margin:0 0 0 8px;padding:4px 0}.stack label input[type="checkbox"]{background:#000;position:relative;width:40px;height:20px;border:1px inset #333;-webkit-appearance:none}.stack label input[type="checkbox"]:checked::after{background:radial-gradient(ellipse at center, #e5f5ff 1%,#6cc7ff 100%);left:19px;border:1px outset #71c3c5;box-shadow:0 0 7px #6cc7ff}.stack label input[type="checkbox"]::after{background:radial-gradient(ellipse at center, #859ead 1%,#284252 100%);left:1px;display:block;width:18px;height:17px;content:"";transition:.1s;border:1px outset #406667;border-radius:2px}.stack label input[type="range"]{background:#000;-webkit-appearance:none}.stack label input[type="range"]::-webkit-slider-runnable-track{height:4px;border:1px inset #232323;box-shadow:0 0 4px #000}.stack label input[type="range"]::-webkit-slider-thumb{background:radial-gradient(ellipse at center, #4b7894 1%,#6cc7ff 100%);position:relative;top:-8px;width:16px;height:16px;border-radius:50%;box-shadow:inset 1px 1px 2px rgba(255,255,255,0.5),2px 2px 2px #000;-webkit-appearance:none}#control{display:flex;align-items:center;justify-content:center}#audio-input,label .param{color:#6cc7ff;background:#040404;margin:0 8px;padding:6px;border:2px groove #353535;box-shadow:inset 0 0 2px rgba(255,255,254,0.1)}#audio-input::-webkit-file-upload-button,label .param::-webkit-file-upload-button{color:#6cc7ff;background:transparent;border:0}#btn-play,#btn-stop,#btn-clear{background:linear-gradient(to bottom, #d8d8d8 0%, #8c8c8c 100%);margin:0 8px;padding:2px 12px;border:1px inset #828282;border-radius:2px;box-shadow:inset 1px 1px 0 #fff, 1px 1px 1px #101010, 0 0 2px #000;text-shadow:1px 1px 0 rgba(255,255,255,0.8)}#btn-play:focus,#btn-stop:focus,#btn-clear:focus{color:#6cc7ff}#audio-visual{vertical-align:top;background:#040404;box-sizing:border-box;width:100%;height:100px;padding:6px;border:2px groove #353535;box-shadow:inset 0 0 2px rgba(255,255,254,0.1)}input[type="range"] div{background:#000}
実際にWAVファイルを読み込んだ例

より具体的な用例

次のような用例もWeb Auido APIで実装が可能です。

  • BiquadFilterNodeとDynamicsCompressorNodeを組み合わせたマルチバンドコンプレッサー
  • DelayNodeを使用したコーラス、フェーザー、フランジャー
  • Impulse Response(IR)ファイルをConvolverNodeで読み込んだリバーブエフェクト、アンプシミュレーター
  • 少しずつFrequencyの異なる複数のOscillatorNodeを組み合わせることでJP-8000のようなSuperSawの生成
  • OscillatorNodeで生成した名器TR-808、TR-909のシミュレーター
  • ブラウザ上でのミキシング・マスタリング、音声ファイルエンコード、楽曲作成
  • WebRTC、getUserMediaを使用したブラウザで動くギター・ベースエフェクター(実際にはレイテンシ問題をどう乗り越えるかが鍵になります)

Web Audio APIではWebやJavaScriptに詳しいことよりも、オーディオに関する知識があれば、より目的に沿った機能を実装することができます(参考にした技術文献を書いている方でWeb業界の方はあまり多くないと個人的に感じたため)。この技術を実際に使うのはWeb業界に限らず、業界外から着目されているという点から今後もWeb Audio APIから目が離せません。