Web Audio APIを使えばブラウザでも簡単に音楽アプリケーションが作れる!のですが、APIがあれば万事OKというわけでもなく、どのような方法でWebアプリケーションに組み込むのかという問題もあります。ということで、この記事ではReactとReduxを使ったWebアプリケーションにどういうアプローチでWeb Audio APIを組み込むのかを考えます。

この記事では簡単なシーケンサーをインクリメンタルに実装します。各項目の冒頭にその時点でのデモとソースコードのリンクを用意しています。デモのリンクは次の絵文字で示しています。

  • :mute: 音の出ないデモ
  • :sound: 音の出るデモ

依存モジュールは次の通りです。BabelとBrowserifyでビルドするというシンプルな構成です。

$ npm install -D babel-preset-es2015 babel-preset-react babelify browserify watchify
$ npm install -S react react-dom react-redux redux

package.json(抜粋)と.babelrcです。全体はこのコミットを参考にしてください。

package.json
{
  "scripts": {
    "build": "browserify src/main.js -o main-bundle.js -t [ babelify ]",
    "watch": "watchify src/main.js -o main-bundle.js --verbose -t [ babelify ]"
  }
}
.babelrc
{
  "presets": [ "es2015", "react" ]
}

ユーザーインターフェースをつくる

:mute: デモ 1ソースコード

さっそくReactとReduxを使ったユーザーインターフェースを作ります。詳細なReactやReduxの使い方はこの記事では扱いません。この記事では特に装飾のないシンプルな画面を使います。

xs-808

構成要素

  • 演奏の開始と停止を制御するボタン
  • テンポ設定のスライダー
  • リズムパターンのマトリックス(横軸が時間、縦軸がパート)

この画面に対応するStateです。"beat"は再生中の位置を表します。

state.json
{
  "isPlaying": false,
  "bpm": 134,
  "matrix": [
    [ 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0 ],
    [ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1 ],
    [ 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0 ],
    [ 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0 ]
  ],
  "beat": 0
}

データフローはこのとおり。

dataflow 1

シーケンサをつくる

:mute: デモ 2ソースコード

次にシーケンサーを作ります。シーケンサーといってもピンキリで色々ありますが、ここではマトリックスに値があればオーディオサンプルを再生、なければ休符という単純なものを考えます。

音源を用意する

Web Audio APIを駆使してゼロから音を作っていくのもなかなか熱いのですが、手っ取り早く音を出したいならオーディサンプルを用意すると楽です。オーディオサンプルは自分で作ったり、購入したり、無料で公開されているのをちょくちょく集めておきます。今回は「tr808 wav」で検索したページからオーディオサンプルセットをダウンロードしました。TR-808というのは昔の超有名なリズムマシンです。

デモでは次のファイルを使用しています。

  • "CB.WAV": カウベル
  • "CH.WAV": ハイハット
  • "SD5050.WAV": スネア
  • "BD0000.WAV": バスドラム

オーディオサンプルを読み込む

Web Audio APIでオーディオサンプルを扱うには、XHRかFetch APIを使ってファイルを読み込み、それをオーディオコンテキストのdecodeAudioData()メソッドでAudioBufferにデコードする必要があります。このあたりはお決まりのパターンなので次のような関数にしておくと便利でしょう。

Sequencer.js
function fetchAsAudioBuffer(audioContext, url) {
  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();

    xhr.open("GET", url, true);
    xhr.responseType = "arraybuffer";

    xhr.onload = () => {
      if (xhr.response) {
        audioContext.decodeAudioData(xhr.response, resolve, reject);
      }
    };
    xhr.onerror = reject;

    xhr.send();
  });
}

演奏用の関数をつくる

オーディオサンプルを使う準備ができたので、それを再生するための関数を用意します。ここではAudioBufferを再生するためのAudioBufferSourceNodeとボリューム調整用のGainNodeを組み合わせています。あとはこの関数を適切なタイミングで呼び出せばシーケンサーの完成です。

Sequencer.js
function perc(destination, playbackTime, { buffer, volume }) {
  const t0 = playbackTime;
  const audioContext = destination.context;
  const bufferSource = audioContext.createBufferSource();
  const gain = audioContext.createGain();

  bufferSource.buffer = buffer;
  bufferSource.start(t0);
  bufferSource.connect(gain);

  gain.gain.value = volume;
  gain.connect(destination);
}

スケジュール管理

Web Audio APIでの正確なタイミング制御には次の記事が参考になります。大胆に要約すると、うまくやるコツは少しずつちょっと先の演奏データを予約していくと良いということが書いてあります。

この記事を参考に作ったスケジューラがあります。

これは時間とコールバック関数を登録しておくと、コンテキストの時間経過に合わせて良い感じで呼び出してくれます。だいたいこういう感じで使えます。

example.js
// AudioContextを引数にして生成する
const sched = new WebAudioScheduler({ context: audioContext });

// スケジューラに登録するコールバック.
// playbackTime にはイベントの時間, args にはパラメータが入っている.
function loop({ playbackTime, args }) {
  console.log(`playbackTime: ${ playbackTime }, index: ${ args.index }`);
  // 次のイベントを登録する.
  sched.insert(playbackTime + 1, loop, { index: args.index + 1 });
}

// スケジューラを開始する. 引数に関数を指定した場合は即実行する.
sched.start(loop, { index: 0 });

setTimeout(() => {
  // スケジューラの停止. 未実行のイベントは破棄される.
  sched.stop();
}, 10 * 1000);

自分でスケジューラを書きたいとか、Web Audio API以外のタイムラインとの同期も必要な場合は、@toyoshimさんの一連の記事が参考になるでしょう。

シーケンサーを実装する

オーディオサンプルの読み込み、WebAudioSchedulerでのスケジュール管理、以上を踏まえてシーケンサーを実装します。ここでsequence()メソッドが進捗の管理をする部分で、マトリックスの状態に応じて演奏関数を呼び出しています。マトリックスの横軸は八分音符で進行します。

Sequencer.js
const WebAudioScheduler = require("web-audio-scheduler");

class Sequencer {
  constructor(audioContext) {
    this.audioContext = audioContext;
    this.sched = new WebAudioScheduler({ context: audioContext });
    this.buffers = [];
    this.bpm = 120;
    this.matrix = [];
    this.beat = 0;
    this.sequence = this.sequence.bind(this);

    [ "CB.WAV", "CH.WAV", "SD5050.WAV", "BD0000.WAV" ].forEach((filename, index) => {
      fetchAsAudioBuffer(audioContext, `samples/${ filename }`).then((audioBuffer) => {
        this.buffers[index] = audioBuffer;
      });
    });
  }

  start() {
    this.beat = 0;
    this.sched.start(this.sequence);
  }

  stop() {
    this.sched.stop();
  }

  sequence({ playbackTime }) {
    const duration = (60 / this.bpm) * (4 / 8);
    const destination = this.audioContext.destination;
    const nextPlaybackTime = playbackTime + duration;

    this.matrix.forEach((track, i) => {
      if (track[this.beat] && this.buffers[i]) {
        perc(destination, playbackTime, {
          buffer: this.buffers[i], volume: 0.2
        });
      }
    });

    if (this.matrix[0]) {
      this.beat = (this.beat + 1) % this.matrix[0].length;
    }
    this.sched.insert(nextPlaybackTime, this.sequence);
  }
}

データフローとの接続

シーケンサーはできましたが、まだデータやユーザーインターフェースとの連携が不足しているため演奏はできません。この項で最初につくったデータフローにシーケンサーを組み込みます。まずデータフローの全体像を示します。Reactとシーケンサー(Web Audio API)は完全に分離されており、これらは赤字で示した3つのパスを通じて連絡します。

dataflow 2

1. setState

:mute: デモ 3ソースコード

"setState"はStateの変化を通知します。Stateは画面に表示するための情報や状態です。これはReduxのstore.getState()store.subscribe()を使って次のように書けます。

main.js
const store = createStore(reducer);
const audioContext = new AudioContext();
const sequencer = new Sequencer(audioContext);

sequencer.setState(store.getState());

store.subscribe(() => {
  sequencer.setState(store.getState());
});

この記事のシーケンサーではテンポとマトリックスの状態を"setState"で受け取っています。これで演奏に必要なデータの連携ができるようになりました。

Sequencer.js
class Sequencer {
  setState(state) {
    this.bpm = state.bpm;
    this.matrix = state.matrix;
  }
}

2. doAction

:sound: デモ 4ソースコード

"doAction"はActionを直接渡して何らかの処理をさせます。Stateとは異なり揮発性のある(保持しない)情報やイベントを処理するのに使います。たとえば、鍵盤楽器を模したアプリケーションのノートオンやノートオフの情報などです。そういったケースではStateの変更を通じて演奏状況を管理しようとすると複雑になるからです。

これはReduxにミドルウェアを適用して、Actionをシーケンサーに渡すようにします。

main.js
const inject = func => () => next => action => next(func(action) || action);
const store = createStore(reducer, applyMiddleware(inject(audioHandler)));

function audioHandler(action) {
  sequencer.doAction(action);
}

この記事のシーケンサーでは"TOGGLE_PLAY"アクションを受け取り、スケジューラの開始/停止を制御しています。(ここでは説明のために開始/停止をdoActionで処理をしていますが、本当はこのケースだとsetStateでやったほうがシンプルで良いです。)

Sequencer.js
class Sequencer {
  doAction(action) {
    switch (action.type) {
    case "TOGGLE_PLAY":
      if (this.sched.state === "suspended") {
        this.start();
      } else {
        this.stop();
      }
      break;
    }
  }
}

3. actions.*

:sound: デモ 5ソースコード

"actions.*"はActionを引き起こします。ReactのコンポーネントからActionを呼ぶのと同じです。これはReduxのbindActionCreators()したActionCreatorを渡しておいて、必要なときに呼び出すようにします。

main.js
const store = createStore(reducer);
const actions = bindActionCreators(actionCreators, store.dispatch);
const audioContext = new AudioContext();
const sequencer = new Sequencer(audioContext, actions);

このシーケンサーでは再生位置を"TICK"アクションで通知しています。他の用途としてはシーケンサーの状態、音の遷移や停止の通知が考えられます。

Sequencer.js
class Sequencer {
  constructor(audioContext, actions) {
    this.audioContext = audioContext;
    this.actions = actions;
  }

  sequence({ playback }) {
    // ...
    this.actions.tick(this.beat);
    // ...
  }
}

ここまででデータフローにシーケンサーを組み込むことができて完璧に動作するようになりました :clap:

応用編

バックグラウンドでも動かしたい

:sound: デモ 6ソースコード

ここまでの実装ではタブがバックグラウンド状態になるとタイマーの精度が落ちて滑らかな再生を維持できません。この辺の基本的な知識や検出方法は次の記事が参考になります。

WebAudioSchedulerでバックグラウンドと戦うには、タブの状態に応じて先読みする時間サイズを調整すれば良いです。ここではタブの状態変更を"CHANGE_VISIBILITY"アクションを通じてシーケンサーに通知し、先読みする時間サイズ(sched.aheadTime)を調整しています。

main.js
document.addEventListener("visibilitychange", () => {
  actions.changeVisibility(document.visibilityState);
});
Sequencer.js
class Sequencer {
  doAction(action) {
    switch (action.type) {
    case "CHANGE_VISIBILITY":
      if (action.visibilityState === "visible") {
        this.sched.aheadTime = 0.1;
      } else {
        this.sched.aheadTime = 1.0;
        this.sched.process(); // 新しい aheadTime で予約中のイベントを実行する
      }
      break;
    }
  }
}

パートを増やしたい

:sound: デモ 7ソースコード

音を出す部分は単純な関数になっているので、同じ要領でパートを増やすことができます。例としてベースパートを追加したバージョンを作ってみました。以前の記事で書いたコインの音StereoPannerNodeを使ったサンプルも同じインターフェースになっているので、組み込んでみると面白いかもしれません。

他の事例

同じような考え方だけどより複雑なものを紹介しておきます。

他のやりかた

この記事の方法ではReactとWeb Audio APIは完全に分離していました。他の考え方としてWeb Audio Weekly 70ではWeb Audio APIを内包したReactコンポーネントを使う記事が紹介されていたり、Web Audio API自体をReactコンポーネントにしたもの(?)があったり、いろいろな試行錯誤があるようです。

また、全く別のアプローチとしてWeb Componentsとしてユーザーインターフェイスと処理を一体化するというのもあります。

おわり

というわけでReact/ReduxのデータフローにWeb Audio APIを組み込む僕なりの方法を紹介しました。

それでは良いウェブオーディオライフを!:santa::santa::santa::santa:

参考

  • xs-808 / この記事でつくったシーケンサーのリポジトリ

おかしな部分や不明な点があれば気軽にコメントしてください。