読者です 読者をやめる 読者になる 読者になる

ハウテレビジョン開発者ブログ

『外資就活ドットコム』を日夜開発している技術陣がプログラミングネタ・業務改善ネタ・よしなしごとについて記していきます。

Web Audio API + firebase + React + material-uiでノイズを組み合わせて評価してもらうサービスを作った

弊社ハウテレビジョンでは、週の1日をR&D dayとして、業務と直接関係しない技術を学んでみたり、今まであまり触れてこなかった領域を調べたりしています。

今回はWeb Audio APIを使ったサービスのプロトタイプを作ってみました。
音声処理が必要なWebサービスは限られますが、作ってみると意外に簡単で楽しいので、空いた時間に何か作ってみてはいかがでしょうか。

背景

弊社エンジニアの中で、noisliというWebサービスがにわかに流行りだしました。 https://www.noisli.com/

これが何かというと、「幾つかの環境音をミキシングし、好きなノイズを作って垂れ流せる」というサービスです。
似たようなサービスは幾つかありますが、作った環境音を簡単に共有出来る点が楽しいです。

さて、各人が好き勝手に作った環境音がSlackで共有されていたのですが、「これは良い」「これは無い」という評価がある程度偏っており、ノイズにも良し悪しがあることが分かりました。

そこで、今回はWeb Audio APIを使って、作ったノイズの評価をリアルタイムで閲覧できるようなサービスを作ってみます。
ここではプロトタイプとして、実運用で考慮すべき諸々は省いています。

開発の手順

  1. 環境構築
  2. Web Audio APIで単一サウンドを再生
  3. 複数サウンドを合成
  4. UIの作成
  5. 再生、評価できるサービスをfirebaseで作る

1はReact、2〜3はWeb Audio API、4はmaterial-ui、5はfirebaseがそれぞれ主なトピックです。

では順番に見てゆきます。

環境構築

f:id:itamisky:20170512133022p:plain

まずはサービスを作成できる環境を整えます。
今回はホビーなプロジェクトですので、以下のコマンドで最小限のReactアプリの土台を作ります。
$ create-react-app noize

Web Audio APIで単一サウンドを再生

Web Audio APIはMDNにドキュメントがありますので、まずそちらを読み進めてゆきます。
https://developer.mozilla.org/ja/docs/Web/API/Web_Audio_API
https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Basic_concepts_behind_Web_Audio_API
https://developer.mozilla.org/ja/docs/Web/API/Web_Audio_API/Using_Web_Audio_API

Audio contextやBufferなどの基本概念に軽く触れた後は、サンプルコードを見つつ動かしてみます。

まずはこちらのコードを利用し、ランダムなノイズを生成し2秒間流してみます。
https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/createBufferSource

componentDidMount() {
  const audioCtx = new (window.AudioContext || window.webkitAudioContext)();

  const channels = 2;
  const frameCount = audioCtx.sampleRate * 2.0;
  const buffer = audioCtx.createBuffer(channels, frameCount, audioCtx.sampleRate);

  for (let channel = 0; channel < channels; channel++) {
    const nowBuffering = buffer.getChannelData(channel);
    for (let i = 0; i < frameCount; i++) {
      nowBuffering[i] = Math.random() * 2 - 1;
    }
  }

  const source = audioCtx.createBufferSource();
  source.buffer = buffer;
  source.connect(audioCtx.destination);
  source.start();
}

createBufferで2秒間分のバッファを作り、そこにランダムな値を放り込んでゆきます。
作ったバッファは出力にAudioContext.destinationを繋ぎます。

これでページをロード(正確にはDOMをマウント)した際にノイズが流れるようになりました。テロですね。

音量調整

このままでは音が大きいので、全体の音量を調整してみます。 GainNodeをsoruceとdestinationの間に挟み、加工するイメージです。 https://developer.mozilla.org/en-US/docs/Web/API/GainNode

// set volume
const gainNode = audioCtx.createGain();
gainNode.gain.value = 0.05;

const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(gainNode);
gainNode.connect(audioCtx.destination);
source.start();

これで小さな音量で再生することができました。 valueは環境にあわせて設定してください。

ファイルを再生する

ずっとランダムなノイズを聞いていいると精神が疲弊してきますので、環境音を再生させてみます。
AudioContext.decodeAudioData() を利用するとファイルからの非同期読み込みが行えます。
https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/decodeAudioData

読み込みはXMLHttpRequestFileReader のいずれかを用いて行えます。
今回はXHRでリソースを取得します。

windy.wav というwavファイルを用意し、public/sound 以下に配置します。
それをXHRで取得してオーディオバッファに渡し、以前と同じ流れで再生をさせます。
再生部分はplayという関数にまとめています。
ちなみに、source.loop = true; を付けるだけでループ再生してくれます。

componentDidMount() {
  const audioCtx = new (window.AudioContext || window.webkitAudioContext)();

  const request = new XMLHttpRequest();
  request.open('GET', 'sound/windy.wav', true);
  request.responseType = 'arraybuffer';
  request.onload = () => {
    const audioData = request.response;
    const source = audioCtx.createBufferSource();
    audioCtx.decodeAudioData(audioData).then((decodedData) => {
      source.buffer = decodedData;
      this.play(audioCtx, source);
      source.loop = true;
    });
  };

  request.send();
}

play(audioCtx, source) {
  // set volume
  const gainNode = audioCtx.createGain();
  gainNode.gain.value = 0.1;

  // connection
  source.connect(gainNode);
  gainNode.connect(audioCtx.destination);

  source.start();
}

再生・停止できるようにする

現在はロード時に強制的に再生するようにしていますが、これをボタンで再生・停止できるようにしてみます。
必要な処理として、以下が挙げられます。

  • 読み込んだオーディオデータを保存して再利用する
  • 再生・停止を切り替える処理を作る
  • ボタンを用意しonClickで切り替える

今回は再生位置は関係ない(再生する度最初から始まる)ので、再生位置の保存などは必要ありませんでした。

まず、オーディオデータを雑にthisに保存するよう変更します。

this.audioData = {
  windy: null,
}
...
request.onload = () => {
  const audioData = request.response;
  this.audioCtx.decodeAudioData(audioData).then((decodedData) => {
    this.audioData.windy = decodedData;
  });
};
...

次に、再生ボタンが押されたら、保存済みデータでsourceを作成する処理を作ります。

start() {
  this.source = this.connect(this.audioData.windy);
  this.source.start();
}


connect(data) {
  const source = this.audioCtx.createBufferSource();

  source.buffer = data;
  source.loop = true;

  // set volume
  const gainNode = this.audioCtx.createGain();
  gainNode.gain.value = 0.1;

  // connection
  source.connect(gainNode);
  gainNode.connect(this.audioCtx.destination);

  return source;
}

また、停止する処理も作成します。

stop() {
  if (this.source) {
    this.source.stop();
  }
}

これらを切り替えて表示にも反映されるよう、toggle関数を作ってonClick時に発火するようにします。

render() {
  const toggle = () => {
    this.state.playing ? this.stop() : this.start();
    this.setState({playing: !this.state.playing});
  };

  return (
    <div className="App">
      <div>{this.state.playing ? 'now playing...' : 'stopped'}</div>
      <button onClick={toggle}>Toggle</button>
    </div>
  );
}

完成です!実際に切り替わるかを試してみてください。

複数サウンドを合成

今までは単一のサウンドファイルを再生してきましたが、複数のサウンドを読み込み、合成して出力してみます。
グラフなので、ノードとエッジを増やすだけで同じように処理出来る気がしますね。

複数ファイルの読み込み

まずはオーディオデータ読み込みを複数ファイルに対応します。
ファイル名を指定してXHRの結果をキャッシュする処理を括りだし、それをファイル名のループで呼び出します。

loadAudioData(fileName, extension) {
  const request = new XMLHttpRequest();
  const soundDir = '/sound/';
  request.open('GET', `${soundDir}${fileName}.${extension}`, true);
  request.responseType = 'arraybuffer';

  request.onload = () => {
    const audioData = request.response;
    this.audioCtx.decodeAudioData(audioData).then((decodedData) => {
      this.audioData[fileName] = decodedData;
    });
  };

  request.send();
}

componentDidMount() {
  this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  ['windy', 'rain'].forEach((name) => this.loadAudioData(name, 'wav'));
}

これで、複数のオーディオファイルデータを簡単に読み込めるようになりました。

サウンドの合成

DTMをやったことがある方には馴染みのある現象かと思いますが、複数の音源から好き勝手に音をだすと、一定のレベルを超えた所で音がビリビリに割れてしまいます。
そんな時はコンプレッサーと呼ばれるものを挟み、大きな音を絞って調整します。

Web Audio APIにもDynamicsCompressorNode というものが用意されており、同じような処理が可能です。
https://developer.mozilla.org/en-US/docs/Web/API/DynamicsCompressorNode

そのため、作成するグラフは、「複数のsource」→「コンプレッサー」→「全体のゲイン(音量)調整」→「出力」という流れになります。

実装としては、まず汎用的な処理にするため、sourceを作成する以下の処理を関数として括りだします。

makeSource(data) {
  const source = this.audioCtx.createBufferSource();
  source.buffer = data;
  source.loop = true;
  return source;
}

その後、複数のsourceをまとめて作成・connect・startさせる処理を記述します。

start() {
  const compressor = this.audioCtx.createDynamicsCompressor();
  const gainNode = this.audioCtx.createGain();
  gainNode.gain.value = 0.1;
  const outputNode = this.audioCtx.destination;
  compressor.connect(gainNode);
  gainNode.connect(outputNode);

  ['windy', 'rain', 'fireplace', 'kiva', 'lavasteam']
    .map((name) => this.makeSource(this.audioData[name]))
    .map((source) => {
      this.sources.push(source);
      source.connect(compressor);
    });

  this.sources.map((source) => source.start());
}

stop() {
  if (this.sources) {
    this.sources.map((source) => source.stop());
    this.sources = [];
  }
}

個々の音源の音量を調整する

個々の音源のバランスを調整するため、それぞれのsourceにもGainNodeをくっつけます。

['windy', 'rain', 'fireplace', 'kiva', 'lavasteam']
  .map((name) => this.makeSource(this.audioData[name]))
  .map((source) => {
    this.sources.push(source);

    const sourceGainNode = this.audioCtx.createGain();
    sourceGainNode.gain.value = 0.3;
    source.connect(sourceGainNode);
    sourceGainNode.connect(compressor);
  });

これで、0.3という固定値を持ったGainNodeを各ソースに紐付けることができました。
後ほど、ここは調整されたパラメータがやってくる予定です。

この段階で、スライダーで全体のゲインを変えられるようにしました(ソースは省略しますが)。
スライダーは範囲を[0, 1000]などにしておき、Gainに渡す際は1/1000すると細かく調整できて便利です。

f:id:itamisky:20170512133055p:plain

UIを作る

さて、このセクションは一休みとして、主にUIを作ります。

リストなどの表示をするのに、通常のulやliをそのまま使うと少し不格好です。
頑張ってCSSを当てても良いですが、ここではMaterial-UIを入れて表示を整えてみます。

今回はSlider、Table、TextField、Buttonの3つのみで事足りました。

http://www.material-ui.com/#/components/slider

http://www.material-ui.com/#/components/table

http://www.material-ui.com/#/components/text-field

http://www.material-ui.com/#/components/flat-button

初期状態の”Welcome to React”だった文字なども変えておきます。
結果、以下のような表示になりました。

f:id:itamisky:20170512133117p:plain

手抜き感はありますが、最低限使えるものにしています。
投票ボタンがキモですが、この段階ではとりあえず置いているだけで、クリックしても何も起きません。

Firebaseとの連携

投票結果を保存して共有するために、サーバーとDBが必要です。
ここではFirebaseの無料プランを使って、お手軽に開発をしてゆきます。

Firebaseの初期化は、コンソールからプロジェクトを作って初期化コードを埋め込むだけの簡単設計です。
https://console.firebase.google.com/?hl=ja

ただし、今回はReactから使うため、NPMとして使います。
https://www.npmjs.com/package/firebase

yarn add firebase -S

DBへの保存

そのままではDBに書き込むのにログインが必要なため、とりあえず全開放のルールを設定します。
FirebaseコンソールのDatabase以下、ルールタブから設定できます。

{
  "rules": {
    ".read": true,
    ".write": true
  }
}

もちろん、プロトタイプ以外では、しっかりとルールを設定しましょう。
全ての情報を誰でも閲覧・編集できてしまうため極めて危険です。

f:id:itamisky:20170512133354p:plain

パラメータとそれに応じた評価を格納すれば良いので、以下のような形式で保存してみます。

/
  - items
    - $id
      - params
        - windy: 0.2,
        - rain: 0.3,
        ...
      - score: 8

認証が無いためシンプルです。
itemの変更を検知するので、ユーザー数が増えそうならもう少し工夫をする必要がありますが、今回はプロトタイプなので手を抜いています。
実際にデータが入ると以下のようになります。

f:id:itamisky:20170512133138p:plain

DBにノイズのデータを書き込む

ユーザーが好きなパラメーターを投稿できるようにします。
まずはこのように適当なフォームを作り、入力を受け取れるようにします。

f:id:itamisky:20170512133156p:plain

“CREATE”ボタンが押されたら、フォームの値を取得し、合計が1.0の時のみデータベースに送信します。

const sum = Object.keys(this.state.values).reduce((result, key) => result + parseFloat(this.state.values[key]), 0.0);
if(sum !== 1.0) {
  this.setState({ message: `sum must be 1.0  - now: ${sum}` });
}
else {
  this.props.sendToDatabase(this.state.values);
  this.setState({ message: 'submitted!' });
}

送信処理は親から渡されており、子コンポーネントはそれにパラメーターを渡して発火するだけです。
送信処理はこれだけです。簡単ですね。

sendToDatabase = (params) => {
  this.db.ref('/items').push().set({
    params: params,
    score: 0,
  });
};

DBからノイズのパラメータを取得する

上記のような処理でデータが詰められてゆきますので、適切に取得する必要があります。
firebaseではDB上のパスを指定し、継続的に変更を検知することができます。
/items の値を検知するには以下のようにコールバックを指定します。

this.db.ref('/items').orderByChild('score').on('value', (snapshot) => {
  const items = snapshot.val();
  this.setState({
    items: items,
  })
})

orderByXXXで特定の値によりソートをすることができます。
なお、indexやlimitなどを考慮する必要がありますが、今回はプロトタイプなので大量のデータを受け取るのも許容してしまいます。

評価を書き込む

評価を変更する箇所ではトランザクション処理にして、複数人が同時に評価してもデータが破損しないようにします。
https://firebase.google.com/docs/database/server/save-data?hl=ja#section-transactions

this.db.ref(`/item/${this.state.playing}/score`).transaction(function (current_value) {
  return (current_value || 0) + value;
});

デプロイする

さて、一応機能ができた所で、デプロイして使ってもらいます。
Firebaseのホスティング機能、create-react-appのbuild機能により、以下のように簡単にデプロイすることができます。
https://firebase.google.com/docs/hosting/deploying?hl=ja

$ firebase init
(hosting.publicをbuildに書き換え)
$ firebase use --add
$ yarn build
$ firebase deploy

database.rulesができた場合は、以前ルールを書き換えた場合と同様に書き換えておきます。

完成

f:id:itamisky:20170512133216p:plain

f:id:itamisky:20170512133232p:plain

まとめ

今回はWeb Audio APIを使い、ちょっとしたサービスのプロトタイプを作ってみました。
create-react-appなどのテンプレート、material-ui、そしてFirebaseを使うとこのようなサービスが簡単に組み立てられますので、プロトタイピングにはオススメです。