Subterranean Flower

音屋の動画によくある音に合わせて動くかっこいいバーをJavaScriptで作る

Author
古都こと
ふよんとえんよぷよぐやま!!!

音楽作る人ってすごいですよね。私も何度か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();
  }
});

いろいろな調整

このままだと周波数の偏りによるバーの長さの偏りとか、バーの長さの調整とか、いろいろ問題あるので、実用の際には各自なんとかしてください。