音楽作る人ってすごいですよね。私も何度かGaragebandを開いて挑戦したことがあるのですが、これがなかなか難しくて。できる人は尊敬します。
音屋さんの動画って必ず音に合わせて動くバーがついてますよね。あの棒グラフみたいなやつ。あれかっこいいですよね。作りたくありません?作りましょう。
音屋のアレ
音屋さんのアレ、かっこいいですよね。音に合わせて動くバー。
オーディオスペクトラムとか言うらしいですね。これJavaScriptで作っちゃいましょう。
今回のソースコード一式
https://github.com/subterraneanflowerblog/audio-spectrum
デモ
まずは普通の棒グラフ
まずは普通の棒グラフスタイルのスペクトラムバーを作りましょう。
まずHTMLからですね。canvasとファイル選択ボタンを置きましょう。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Audio Spectrum</title>
<script src="main.js" defer></script>
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
}
#display {
display: block;
margin: 1em 0;
}
#open-file {
background-color: rgb(112, 125, 198);
color: white;
text-align: center;
padding: 0.5em 1em;
cursor: pointer;
}
#file-input {
display: none;
}
</style>
</head>
<body>
<canvas id="display"></canvas>
<label id="open-file">
音楽ファイルを開く
<input id="file-input" type="file" accept="audio/*">
</label>
</body>
</html>
HTMLはこれだけです。あとはJavaScriptの方をいじっていきます。HTML側で「main.js」としてるのでファイル名はmain.jsですね。
まずcanvasを取得してサイズをセットします。
const canvas = document.querySelector('#display');
const canvasContext = canvas.getContext('2d');
canvas.width = 500;
canvas.height = 255;
次にファイルをdataURLに変換する関数を作ります。HTMLのinputから読み込まれたファイルを再生できるようにするためです。
// 音楽ファイルをdataURLに変換する
// Promiseを返す
async function convertAudioFileToDataUrl(file) {
const reader = new FileReader();
const loadPromise = new Promise((resolve, reject) => {
reader.onload = (event) => {
resolve(event.target.result);
};
});
reader.readAsDataURL(file);
return loadPromise;
}
Promise返すようにしておくとあとでの扱いが楽です。Promiseについてご存じない方は「Promiseとasync/awaitでJavaScriptの非同期処理をシンプルに記述する」をご覧ください。
次に描画関数です。解析したスペクトラムが渡ってくると仮定して、コードを書きます。
// canvasにスペクトラムバーを描画する
function render(spectrum) {
// canvasの幅を均等に割り振る
const barWidth = Math.round(canvas.width / spectrum.length);
// 色は黒
canvasContext.fillStyle = 'rgb(60, 60, 60)';
// 前の描画を消す
canvasContext.clearRect(0, 0, canvas.width, canvas.height);
for(let i = 0; i < spectrum.length; i++) {
// 四角形の描画
// fillRectは本来左上から幅と高さを指定するが、ここでは左下から指定している
// やり方は簡単で、高さ(スペクトラムの値)をマイナスにするだけ
canvasContext.fillRect(barWidth * i, canvas.height, barWidth, -spectrum[i]);
}
}
内容としては配列として渡ってくるスペクトラムを、四角形で描画するだけです。スペクトラムが更新されるたびにこの関数を呼びます。
さて、ここからが本題。次はファイル選択時の処理を書きます。
ファイルが選択されたらWeb Audio APIを使って分析用のコンテキストを作成します。
Web Audio APIでは「入力 → 処理 → 処理 → … → 出力」という風にノードをつなぎます。今回は入力はファイル(から変換したdataURL)、処理は音声分析、出力はいつも通りのスピーカー、という具合にします。
先にコードを貼っちゃいましょうか。
// ファイルが選択されたら
const fileInput = document.querySelector('#file-input');
let audio = null;
let audioSource = null;
let intervalId = null;
fileInput.addEventListener('change', async (event) => {
// Web Audio API周りの準備
const audioContext = new AudioContext();
const analyzerNode = audioContext.createAnalyser(); // 音分析ノード
// 2回目以降のときは、前のオーディとタイマーを破棄してから処理にうつる
if(audio) {
audio.pause();
audio.src = '';
}
if(audioSource) {
audioSource.disconnect();
}
if(intervalId) {
clearInterval(intervalId);
}
// FFTのウィンドウサイズ
// 値は2の累乗(2, 4, 8, 16, 32, ...)
analyzerNode.fftSize = 128;
const file = fileInput.files[0];
if(file) {
// スペクトラムを保持するUint8Arrayを用意
// サイズはfftSizeの半分(=frequencyBinCount)
const spectrumArray = new Uint8Array(analyzerNode.frequencyBinCount);
// 選択されたファイルをdataURLにしてaudio要素に突っ込む
audio = new Audio();
audio.src = await convertAudioFileToDataUrl(file);
// 選択ファイル -> 分析ノード -> 出力(スピーカー)
// の順でつなぐ
audioSource = audioContext.createMediaElementSource(audio);
audioSource.connect(analyzerNode);
analyzerNode.connect(audioContext.destination);
// 定期的に値を見て描画する
// requestAnimationFrameでもok
intervalId = setInterval((event) => {
// ノードから周波数データを取り出す
analyzerNode.getByteFrequencyData(spectrumArray);
// 描画する
render(spectrumArray);
}, 1/60);
// 再生開始
audio.play();
}
});
ファイルが選択されたらWeb Audioコンテキストの準備をします。
まず最初に中間処理である分析ノードを作ります。分析ノードでは様々な音声解析を自動的にしてくれます。今回は分析ノードを使って音の特徴を取り出し、可視化したいと思います。
次に解析ノードで使われる高速フーリエ変換(Fast Fourier Transform, 略してFFT)のウィンドウサイズを決めます。ウィンドウというのはFFTが処理するときに切り出してくる範囲だと思っててください。
準備ができたらあらかじめ分析データ保存用のUint8Arrayを作っておき、Web Audioのノードを、入力(audio要素) → 分析 → 出力(スピーカー)の順で繋ぎます。
そしてsetIntervalでタイマーを利用し、定期的に分析ノードの分析結果を覗いて描画します。分析ノードのgetByteFrequencyDataメソッドを使うことで、周波数データの配列が手に入ります。
これだけで完成です!Web Audio APIすごいですね。フーリエ変換とかさっぱりわからなくても音声の分析ができました。
あとは動かしてみてください。
ここまでのコード
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Audio Spectrum</title>
<script src="main.js" defer></script>
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
}
#display {
display: block;
margin: 1em 0;
}
#open-file {
background-color: rgb(112, 125, 198);
color: white;
text-align: center;
padding: 0.5em 1em;
cursor: pointer;
}
#file-input {
display: none;
}
</style>
</head>
<body>
<canvas id="display"></canvas>
<label id="open-file">
音楽ファイルを開く
<input id="file-input" type="file" accept="audio/*">
</label>
</body>
</html>
const canvas = document.querySelector('#display');
const canvasContext = canvas.getContext('2d');
canvas.width = 500;
canvas.height = 255;
// 音楽ファイルをdataURLに変換する
// Promiseを返す
async function convertAudioFileToDataUrl(file) {
const reader = new FileReader();
const loadPromise = new Promise((resolve, reject) => {
reader.onload = (event) => {
resolve(event.target.result);
};
});
reader.readAsDataURL(file);
return loadPromise;
}
// canvasにスペクトラムバーを描画する
function render(spectrum) {
// canvasの幅を均等に割り振る
const barWidth = Math.round(canvas.width / spectrum.length);
// 色は黒
canvasContext.fillStyle = 'rgb(60, 60, 60)';
// 前の描画を消す
canvasContext.clearRect(0, 0, canvas.width, canvas.height);
for(let i = 0; i < spectrum.length; i++) {
// 四角形の描画
// fillRectは本来左上から幅と高さを指定するが、ここでは左下から指定している
// やり方は簡単で、高さ(スペクトラムの値)をマイナスにするだけ
canvasContext.fillRect(barWidth * i, canvas.height, barWidth, -spectrum[i]);
}
}
// ファイルが選択されたら
const fileInput = document.querySelector('#file-input');
let audio = null;
let audioSource = null;
let intervalId = null;
fileInput.addEventListener('change', async (event) => {
// Web Audio API周りの準備
const audioContext = new AudioContext();
const analyzerNode = audioContext.createAnalyser(); // 音分析ノード
// 2回目以降のときは、前のオーディとタイマーを破棄してから処理にうつる
if(audio) {
audio.pause();
audio.src = '';
}
if(audioSource) {
audioSource.disconnect();
}
if(intervalId) {
clearInterval(intervalId);
}
// FFTのウィンドウサイズ
// 値は2の累乗(2, 4, 8, 16, 32, ...)
analyzerNode.fftSize = 128;
const file = fileInput.files[0];
if(file) {
// スペクトラムを保持するUint8Arrayを用意
// サイズはfftSizeの半分(=frequencyBinCount)
const spectrumArray = new Uint8Array(analyzerNode.frequencyBinCount);
// 選択されたファイルをdataURLにしてaudio要素に突っ込む
audio = new Audio();
audio.src = await convertAudioFileToDataUrl(file);
// 選択ファイル -> 分析ノード -> 出力(スピーカー)
// の順でつなぐ
audioSource = audioContext.createMediaElementSource(audio);
audioSource.connect(analyzerNode);
analyzerNode.connect(audioContext.destination);
// 定期的に値を見て描画する
// requestAnimationFrameでもok
intervalId = setInterval((event) => {
// ノードから周波数データを取り出す
analyzerNode.getByteFrequencyData(spectrumArray);
// 描画する
render(spectrumArray);
}, 1/60);
// 再生開始
audio.play();
}
});
円形スペクトラム
でも音動画でよく見るのって円形のかっこいいやつじゃん。あれ作りたいですよね。作りましょう!
デモ
円形にする
もうデータは取れてるのであとは描画周り変えるだけです。
まずcanvasのサイズを正方形にしましょう。
const canvas = document.querySelector('#display');
const canvasContext = canvas.getContext('2d');
canvas.width = 500;
canvas.height = 500;
あとは描画関数を書き換えます。気をつけるのは、今度は円なので2πを等分するというところですね。
// canvasにスペクトラムを描画する
function render(spectrum) {
// canvasの中心座標
const center = { x: Math.round(canvas.width / 2), y: Math.round(canvas.height/2) };
// canvasの幅を均等に割り振る
// 円なので360度(2π)を分割する
const barRad = 2 * Math.PI / spectrum.length;
// 円の半径
const innerRadius = 60;
const outerRadius = 250;
const diffRadius = outerRadius - innerRadius;
// 前の描画を消す
canvasContext.clearRect(0, 0, canvas.width, canvas.height);
for(let i = 0; i < spectrum.length; i++) {
// 色相を回転
const barDegree = barRad * i * 180 / Math.PI;
canvasContext.fillStyle = `hsl(${barDegree}, 80%, 60%)`;
// バーの開始角度・終了角度を計算
const startRad = barRad * i;
const endRad = barRad * (i + 1);
// バーの開始点・終了点を計算
const startX = center.x + innerRadius * Math.cos(startRad);
const startY = center.y + innerRadius * Math.sin(startRad);
const endX = center.x + innerRadius * Math.cos(endRad);
const endY = center.y + innerRadius * Math.sin(endRad);
// 値からバーの長さを計算
const normalizedSpectrum = spectrum[i] / 255; // 0.0から1.0までの値に変換する。最大255なので255で割ればいい
const barRadius = normalizedSpectrum * diffRadius + innerRadius;
// 描画開始
canvasContext.beginPath();
// まず円弧を描く
canvasContext.arc(center.x, center.y, innerRadius, startRad, endRad);
// 次にバーを描く
// バーの半径から外円上の点を割り出し、
// 内円から外円へ四角形を描く
canvasContext.moveTo(startX, startY);
canvasContext.lineTo(barRadius * Math.cos(startRad) + center.x, barRadius * Math.sin(startRad) + center.y);
canvasContext.lineTo(barRadius * Math.cos(endRad) + center.x, barRadius * Math.sin(endRad) + center.y);
canvasContext.lineTo(endX, endY);
// 塗る
canvasContext.fill();
}
}
バーは、内円の開始位置 → 外円上の点 → 外円上の点 → 内円の終了位置、の順で線を引きます。
三角関数が使われていますが、わからない方は「JavaScriptの三角関数とcanvasで円運動アニメーションを作る」をどうぞ。
これだけで動作します。動かしてみてください。
ここまでのコード
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Audio Spectrum</title>
<script src="main.js" defer></script>
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
}
#display {
display: block;
margin: 1em 0;
}
#open-file {
background-color: rgb(112, 125, 198);
color: white;
text-align: center;
padding: 0.5em 1em;
cursor: pointer;
}
#file-input {
display: none;
}
</style>
</head>
<body>
<canvas id="display"></canvas>
<label id="open-file">
音楽ファイルを開く
<input id="file-input" type="file" accept="audio/*">
</label>
</body>
</html>
const canvas = document.querySelector('#display');
const canvasContext = canvas.getContext('2d');
canvas.width = 500;
canvas.height = 500;
// 音楽ファイルをdataURLに変換する
// Promiseを返す
async function convertAudioFileToDataUrl(file) {
const reader = new FileReader();
const loadPromise = new Promise((resolve, reject) => {
reader.onload = (event) => {
resolve(event.target.result);
};
});
reader.readAsDataURL(file);
return loadPromise;
}
// canvasにスペクトラムを描画する
function render(spectrum) {
// canvasの中心座標
const center = { x: Math.round(canvas.width / 2), y: Math.round(canvas.height/2) };
// canvasの幅を均等に割り振る
// 円なので360度(2π)を分割する
const barRad = 2 * Math.PI / spectrum.length;
// 円の半径
const innerRadius = 60;
const outerRadius = 250;
const diffRadius = outerRadius - innerRadius;
// 前の描画を消す
canvasContext.clearRect(0, 0, canvas.width, canvas.height);
for(let i = 0; i < spectrum.length; i++) {
// 色相を回転
const barDegree = barRad * i * 180 / Math.PI;
canvasContext.fillStyle = `hsl(${barDegree}, 80%, 60%)`;
// バーの開始角度・終了角度を計算
const startRad = barRad * i;
const endRad = barRad * (i + 1);
// バーの開始点・終了点を計算
const startX = center.x + innerRadius * Math.cos(startRad);
const startY = center.y + innerRadius * Math.sin(startRad);
const endX = center.x + innerRadius * Math.cos(endRad);
const endY = center.y + innerRadius * Math.sin(endRad);
// 値からバーの長さを計算
const normalizedSpectrum = spectrum[i] / 255; // 0.0から1.0までの値に変換する。最大255なので255で割ればいい
const barRadius = normalizedSpectrum * diffRadius + innerRadius;
// 描画開始
canvasContext.beginPath();
// まず円弧を描く
canvasContext.arc(center.x, center.y, innerRadius, startRad, endRad);
// 次にバーを描く
// バーの半径から外円上の点を割り出し、
// 内円から外円へ四角形を描く
canvasContext.moveTo(startX, startY);
canvasContext.lineTo(barRadius * Math.cos(startRad) + center.x, barRadius * Math.sin(startRad) + center.y);
canvasContext.lineTo(barRadius * Math.cos(endRad) + center.x, barRadius * Math.sin(endRad) + center.y);
canvasContext.lineTo(endX, endY);
// 塗る
canvasContext.fill();
}
}
// ファイルが選択されたら
const fileInput = document.querySelector('#file-input');
let audio = null;
let audioSource = null;
let intervalId = null;
fileInput.addEventListener('change', async (event) => {
// Web Audio API周りの準備
const audioContext = new AudioContext();
const analyzerNode = audioContext.createAnalyser(); // 音分析ノード
// 2回目以降のときは、前のオーディとタイマーを破棄してから処理にうつる
if(audio) {
audio.pause();
audio.src = '';
}
if(audioSource) {
audioSource.disconnect();
}
if(intervalId) {
clearInterval(intervalId);
}
// FFTのウィンドウサイズ
// 値は2の累乗(2, 4, 8, 16, 32, ...)
analyzerNode.fftSize = 128;
const file = fileInput.files[0];
if(file) {
// スペクトラムを保持するUint8Arrayを用意
// サイズはfftSizeの半分(=frequencyBinCount)
const spectrumArray = new Uint8Array(analyzerNode.frequencyBinCount);
// 選択されたファイルをdataURLにしてaudio要素に突っ込む
audio = new Audio();
audio.src = await convertAudioFileToDataUrl(file);
// 選択ファイル -> 分析ノード -> 出力(スピーカー)
// の順でつなぐ
audioSource = audioContext.createMediaElementSource(audio);
audioSource.connect(analyzerNode);
analyzerNode.connect(audioContext.destination);
// 定期的に値を見て描画する
// requestAnimationFrameでもok
intervalId = setInterval((event) => {
// ノードから周波数データを取り出す
analyzerNode.getByteFrequencyData(spectrumArray);
// 描画する
render(spectrumArray);
}, 1/60);
// 再生開始
audio.play();
}
});
いろいろな調整
このままだと周波数の偏りによるバーの長さの偏りとか、バーの長さの調整とか、いろいろ問題あるので、実用の際には各自なんとかしてください。