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}
より具体的な用例
次のような用例も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から目が離せません。