タイトルの通りです。

ReactアプリにWeb Audio API を導入しようと思ったのですが、意外に苦労したので、忘れないようブログとして残すことにしました。

なのでこの記事をおすすめできるのは、次のような方だと思っています。

  • React の知識そこそこ
  • Web Auido API に対して無知
  • 音声の加工処理には興味ある

ちなみに Web Audio API とはブラウザで音声処理を行うための機能のことです。基本的には再生中の音声をその場で加工する仕組みになっています。ただ単に音声処理したいだけ(再生が不要)の場合、少し扱いが特殊になるので注意しましょう。

目標:任意の音源にReverb効果を付与し再生する。

今回の記事の目標です。

ローカルにある音楽ファイルに、お風呂場で響く音のようなやまびこ効果(Reverb)を付与して再生したいと思います。ここでは基本 JavaScript を使っていこうと思います。

目次

  1. Web Audio API の仕組み
    • Web Audio API ができること
    • 再生するまでの処理
    • 音声の加工方法
  • Web Audio API を使ってみる
    • React 環境セットアップ
    • 音声ファイルの読み込み
    • 単純に再生してみる
    • Reverb 効果を付与してみる
  • 私の環境
    OS Windows 10
    Node v10.19.0
    npm 6.13.4
    editor vscode

    Web Audio API の仕組み

    はじめに大雑把ですが、Web Audio API の仕組みを説明したいと思います。

    Web Audio API ができること

    私が使った限り Web Audio API は以下のようなことができます。もし望んだ機能が備わっていなくても、AudioWorkletという仕組みを用いれば、好きな機能を自作することもできます。

    • フィルタ(ハイパス、ローパス、バンドパス等、8種類)
    • やまびこ効果(reverb)付与
    • チャンネル数変更・パンニング
    • ダイナミックレンジ圧縮(コンプレッサー)
    • 周期的な波形生成(オシレーター)
    • 時間軸での波形情報の取得
    • 周波数軸での波形解析(FFT)
    • 任意機能の作成「Audio Worklet」

    再生するまでの処理

    上記機能を利用するためには、まず Web Audio API で音声を再生する必要があります。手順としては次のようになります。

    1. AudioContext を1つ作成しておく(Web Audio API機能の中心となるオブジェクト)
    2. 音声ファイルを読み込み、APIが利用できる形に変換。SourceNode ※を生成しソースとして設定する
    3. AudioContext が持つ出力先と、SouceNode を接続する
    4. SourceNode の持つ関数 start() を好きなタイミングで発火する(ボタンクリックイベント等)

    ※正確には、MediaElementAudioSoruceNode と AudioBufferSourceNode の2種類存在しますがここではまとめて「SourceNode」と呼びます。ちなみに今回利用するのは AudioBufferSourceNode です。

    音声の加工方法

    音声さえ再生できれば、Web Audio API の機能は簡単に利用できます。以下の図のように、AudioContext から各種機能を持つオブジェクト(以降AudioNodeと呼称)を生成し、出力先との接続の間に挟みます。この状態で再生すれば、挟まれたAudioNodeの設定通りに加工音声が再生されます。

    ちなみにオシレーターはSourceNodeの1つです。単純な音声(サイン波、矩形波、三角等)なら音声ファイルがなくてもオシレーターで生成して再生できます。

    Web Audio API を使ってみる

    React 環境セットアップ

    ブラウザ標準の機能であるWeb Audio API とは異なり、React.js は個別で環境を用意しないと利用できません。一応、JavaScriptのライブラリの一つということですね。React 公式ページに従って「Create React App」のコマンドを打ち込んでいきます。コマンドプロンプトを立ち上げ、作業フォルダで実行しましょう。

    以上です。

    これで「audio-app」というフォルダが自動で生成されるはずです。src フォルダ内の App.js に新しくコードを追加していくので、不要なコードはあらかじめ削除しておきましょう。index.js と App.js だけ整形して残します。

    import React from 'react';
    import ReactDOM from 'react-dom';
    import App from './App';
    ReactDOM.render(
    <React.StrictMode>
    <App />
    </React.StrictMode>,
    document.getElementById('root')
    );
    function App() {
    return (
    <div>
    </div>
    );
    }
    export default App;

    (1つ注意点として、このコマンドを使うためには Node >= 10.16 及び npm >= 5.6 の環境が必要になります。)

    音声ファイルの読み込み

    まずは、ローカルにある音声ファイルをブラウザが認識・利用できるようにしたと思います。手段としては次の3つが考えられますが今回はFile API の <input> タグを利用したいと思います。ドラッグ&ドロップの方法も面白いのでいつか記事にしたいです。

    • File API <input>タグでローカルファイルを選択する
    • 音声ファイルをブラウザ上にドラッグ&ドロップし JavaScript のイベントとして検出する
    • react アプリ内のファイルとしてあらかじめ配置しておく

    試しに、ファイルサイズを取得し、表示してみます。App.js を変更しコマンド「npm start」 を実行します。このコードだとテキストや画像ファイルも読み込めてしまいますが、今回は気にしないことにします。好きな音楽ファイルを選択して、ファイルサイズが表示されれば成功です。

    function App() {
     // イベントコールバック
    const handleChangeFile = (event) => {
    const _file = event.target.files[0];
    console.log(_file.size);
    };
    return (
    <div>
    <input type="file" onChange={handleChangeFile} />
    </div>
    );
    }
    export default App;

    単純に再生してみる

    いよいよ Web Audio API を利用していきます。React で利用する際には、画面の再描画処理に注意する必要があります。なぜなら、AudioContext が複数作成される場合があるからです。

    AudioContext は1つのみ作成するのが好ましいです。しかしコードの記述方法によっては、Reactが再描画処理を行った際に AudioContext を再生成してしまう可能性があります。そうなると音声が正しく再生されなくなります。

    なので、画面の再描画が行われても AudioContext は不変となるようにする必要があります。この解決方法として react の機能 「useRef」を利用することにします。

    AudioContext 作成

    import React, { useEffect, useRef } from "react";
    function App() {
    // 再描画の影響を受けない不変なオブジェクト
    const audioContext = useRef(null);
    // 初期化
    useEffect(()=>{
    audioContext.current = new AudioContext();
    },[])
    // イベントコールバック
    const handleChangeFile = (event) => {
    const _file = event.target.files[0];
    console.log(_file.size);
    };
    return (
    <div>
    <input type="file" onChange={handleChangeFile} />
    </div>
    );
    }
    export default App;

    useRef を利用して作成されたオブジェクトは、reactの再描画処理の影響をうけません。再描画処理によってオブジェクトが変化することはなく、またこのオブジェクトがきっかけで再描画処理が実行されることもありません。

    理想としては、1回目の描画時に、1度だけ AudioContext を作成したいところです。なのでReactのライフサイクル処理を記述できる useEffect を利用します。第2引数に空白の配列を渡すと、第1引数の関数は初めの描画時の1度しか行われません。

    音声ファイルの変換

    import React, { useState, useEffect, useRef } from "react"; // 追加
    function App() {
    // 再描画の影響を受けない不変なオブジェクト
    const audioContext = useRef(null);
    // 内部状態
    const [audioBuffer, setAudioBuffer] = useState(null); // 追加
    // 初期化
    useEffect(() => {
    audioContext.current = new AudioContext();
    }, []);
    // イベントコールバック
    const handleChangeFile = async (event) => {
    const _file = event.target.files[0];
    const _audioBuffer = await audioContext.current.decodeAudioData( // 追加
    await _file.arrayBuffer()
    );
    setAudioBuffer(_audioBuffer); // 追加
    };
    return (
    <div>
    <input type="file" onChange={handleChangeFile} />
    </div>
    );
    }
    export default App;

    読み込んだ音声ファイルをWeb Audio API が利用できる形に変換します。変換するタイミングはファイルが読み込まれた瞬間、つまり<input> タグのイベント発生時にします。

    ここでは File API の File オブジェクトを 固定長バイナリデータ(ArrayBuffer オブジェクト)に変換。さらにWeb Audio API の AudioBuffer オブジェクトに変換しています。

    変換したデータはアプリの内部情報として State 管理したいと思います。State 管理された値が変更されると、画面の再描画が自動的に行われるからです。今回程度では特に意味はないですが、画面の情報量が増えた時には非常に便利です。内部状態 State をいつ変更するかだけを考えればよくなります。ここでは React の useState を利用したいと思います。

    再生ボタンとそのコールバック

    import React, { useState, useEffect, useRef } from "react";
    function App() {
     // ...
     const [audioBuffer, setAudioBuffer] = useState(null);
     // ...
     // 既存コードは省略
     
     // 追加
    const handleClickPlay = () => {
    // 自動再生ブロックにより停止されたオーディオを再開させる
    if (audioContext.current.state === "suspended") {
    audioContext.current.resume();
    }
    // ソースノード生成 + 音声を設定
    const sourceNode = audioContext.current.createBufferSource();
    sourceNode.buffer = audioBuffer;
    // 出力先に接続
    sourceNode.connect(audioContext.current.destination);
    // 再生発火
    sourceNode.start();
    };
    return (
    <div>
    <input type="file" onChange={handleChangeFile} />
    <button onClick={handleClickPlay}>再生</button>
    </div>
    );
    }
    export default App;

    これで音声が再生できるようになりました。

    再生ボタンが押された時 SoruceNode 生成から再生までを一度に行うという内容です。

    ここで一つ注意なのですが、最近のブラウザは音声の自動再生をデフォルトで禁止しているので、AudioContextの初期状態は「suspended(一時停止)」になっています。なので再生前には、その状態を「running」にする必要があります。

    Reverb効果を付与してみる

    再生ができたのなら、後は加工したい処理を出力前に行うのみです。今回はやまびこ効果(Reverb)を付与したいので、「DelayNode」と「GainNode」というAudioNode を利用したいと思います。

    DelayNode

    DelayNodeは、接続された SourceNode または 他AudioNode からの入力音声を指定時間遅らせて出力することができます。通常の音声と、DelayNodeで遅らせた音声を重ねれば、それはきっと、やまびこのような音になるのではないでしょうか。

    GainNode

    GainNodeは、入力音声の大きさを変更できます。やまびこで遅れて帰ってくる音声は、元の音声より減衰しているはずです。今回は元音声の8割ほどの大きさと考えます。加えて、単純に音声を足し合わせると音声が大きくなりすぎて音割れする可能性があります。なので、原音とやまびこ、それぞれ音声の大きさは半減しておこうと思います。

    接続イメージ

    次のイメージでAudioNode を作成してつなげていきます。一定時間遅らせた音声を減衰させ、さらにその音声を一定時間遅らせ減衰させる。この繰り返しで、まるで音が反射してどんどん小さくなっていくように聞こえるはずです。完成したコードも記述しておきます。

    import React, { useState, useEffect, useRef } from "react";
    function App() {
    // 再描画の影響を受けない不変なオブジェクト
    const audioContext = useRef(null);
    const delayNode = useRef(null); // 遅延時間
    const gainNodeDry = useRef(null); // 原音の大きさ
    const gainNodeWet = useRef(null); // やまびこの大きさ
    const gainNodeFeedBack = useRef(null); // やまびこ減衰率
    // 内部状態
    const [audioBuffer, setAudioBuffer] = useState(null);
    // 初期化
    useEffect(() => {
    audioContext.current = new AudioContext();
    delayNode.current = audioContext.current.createDelay();
    gainNodeDry.current = audioContext.current.createGain();
    gainNodeWet.current = audioContext.current.createGain();
    gainNodeFeedBack.current = audioContext.current.createGain();
    delayNode.current.delayTime.value = 0.15; // 遅延時間を指定(0.15秒)
    gainNodeDry.current.gain.value = 0.5; // 原音の大きさを指定(0.5倍)
    gainNodeWet.current.gain.value = 0.5; // やまびこの大きさを指定(0.5倍)
    gainNodeFeedBack.current.gain.value = 0.8; // やまびこの減衰率を指定(0.8倍)
    }, []);
    // イベントコールバック
    const handleChangeFile = async (event) => {
    const _file = event.target.files[0];
    const _audioBuffer = await audioContext.current.decodeAudioData(
    await _file.arrayBuffer()
    );
    setAudioBuffer(_audioBuffer);
    };
    const handleClickPlay = () => {
    // 自動再生ブロックにより停止されたオーディオを再開させる
    if (audioContext.current.state === "suspended") {
    audioContext.current.resume();
    }
    // ソースノード生成 + 音声を設定
    const sourceNode = audioContext.current.createBufferSource();
    sourceNode.buffer = audioBuffer;
    // 出力先に接続
    sourceNode.connect(delayNode.current);
    sourceNode.connect(gainNodeDry.current);
    delayNode.current.connect(gainNodeWet.current);
    delayNode.current.connect(gainNodeFeedBack.current);
    gainNodeFeedBack.current.connect(delayNode.current);
    gainNodeDry.current.connect(audioContext.current.destination);
    gainNodeWet.current.connect(audioContext.current.destination);
    // 再生発火
    sourceNode.start();
    };
    return (
    <div>
    <input type="file" onChange={handleChangeFile} />
    <button onClick={handleClickPlay}>再生</button>
    </div>
    );
    }
    export default App;

    以上になります。

    <input> タグを追加して、減衰率等パラメータを変更できるようにしても面白いと思います。

    加えて、Reverb効果は、ConvolverNode を利用しても実現することができます。これにはインパルス応答という音声ファイルが必要になるのですが、話が長くなりそうなので今回紹介するのは控えることにします。疑似的なインパルス応答を生成できるサイトは存在するようなので気になった方は一緒に調べてみてください。畳み込み積分など理解できる方は、きっと直感的に利用できると思います。

    まとめ

    今回の目標は「任意の音源にReverb効果を付与し再生する」でした。実際に動かすことができると結構音が響いていたと思います。Web Audio API を使えば、他にもかなり本格的な音声処理が実現できます。ブラウザだけでこれだけの機能があるのは正直驚きです。

    まだまだ説明不足な点もあるかもしれませんが、この記事が誰かの役に立てれば幸いです。