この記事は『CRESCO Advent Calendar 2021』 3日目の記事です。

 

ずいぶんとマニアックな記事になってしまいました。

突然ですが、皆さんは Web Audio API の Audio Worklet という機能をご存じでしょうか。

Audio Worklet は Web Audio API に定義されていないユーザー任意の音声処理を行う機能になります。

もちろん音声処理そのものは手動で実装する必要があるのですが、Audio Worklet を用いればそういった手動の実装を緩和しつつ、Web Audio APIの機能(Audio Node)の1つとして登録・使用することができ非常に便利です。

この記事では、Audio Worklet が内部でどのような処理を行っているのか、実装例を見ながら紹介していきたいと思います。

目標:Audio Worklet で VUメーターを作成する

今回の記事の目標です。

Audio Workletの具体的な使用方法を知るための実装例として、W3Cのドキュメント(日本語訳)にあるVUメーターを参考にしました。普段 React をよく利用しているので、ReactアプリとしてこのVUメーターを動かしてみたいと思います。

必要そうな前提知識

  • Web Audio API
    • そこそこ
    • 音声ファイルを読み込んで再生する程度の内容
  • React
    • そこそこ
    • useState, useEffect, useRef あたりのフックが理解できれば
  • 以前書いた記事「React.jsでWeb Audio APIを使ってみる」の内容をそのまま流用している点があるので、先に読んでもらえるとより理解しやすいかもしれません。

    目次

    1. Reactでの実装例概要
      1. 開発環境
      2. ファイル構成
      3. イメージ画像
  • 実際のコード
    1. 「vuMeterProcessor.js」
    2. 「VUMeterNode.js 」
    3. 「main.js」
  • React での実装例概要

    上記でも紹介したように、この記事の実装は、W3CのドキュメントにあるVUメーターを参考にしています。VUとはVolume Unit のことです。再生時点の音声の平均的な音量を示すメーターで、単位はアナログの音量単位「dBu」です。デジタルの音量単位「dBFS」との関係が色々あるようなのですが、ここでの説明は省きたいと思います。

    開発環境

    • OS:windows 10
    • Node:v14.16.1
    • npm:6.13.4

    ファイル構成

    「create-react-app」で自動生成したディレクトリにいくつかファイルを追加しています。ディレクトリ名は「audio-app」になります。

    • audio-app
      • public
        • audioWorklet(新規作成ディレクトリ)
          • vuMeterProcessor.js(追加ファイル)
  • index.html
  • その他自動生成ファイル
  • src
    • volumeUnitMeter(新規作成ディレクトリ)
      • Main.js(追加ファイル)
      • VUMeterNode.js(追加ファイル)
  • index.js
  • その他自動生成ファイル
  • イメージ画像

    アプリの全体像ですが、実際に動かすと次の画像のようになります。

    音声ファイルを選択後、再生ボタンを押下することで音量の測定を開始します。

    実際のコード

    今回 Audio Worklet を利用したVUメーターを作成するにあたって、新しく作成したファイルは3つです。

    それぞれ「vuMeterProcessor.js」「VUMeterNode.js」「main.js」になります。

    以下簡単な概要です。

    • vuMeterProcessor.js:任意の音声処理の作成・登録を行う
    • VUMeterNode.js:登録した音声処理を利用する新クラスを作成する
    • main.js:新クラスを React コンポーネント内で利用する

    vuMeterProcessor.js

    利用したい音声処理(今回は音量の計測)を AudioWorkletProcessor として Web Audio API に登録します。

    今回の登録名は「vumeter」とします。

    コンストラクターでは、音声処理に利用したい変数を定義しておきます。引数の options は後述するVUMeterNode クラスで指定したものが取得できます。options は使用しなくても特に問題ありません。

    具体的な音声処理は、関数 process() 内に記述します。js のメインスレッドとは別のスレッドで動作するため、画面描画の遅延等とは関係なく動作します。サクサク動いて快適です。入力を検知しなくなるまで自動で繰り返し実行されます。

    音量が極端に小さいときは、入力がない状態と変わらないので、返り値に false を返して process() の動作を手動で中断させましょう。

    const SMOOTHING_FACTOR = 0.9; // 急激な音量変化の抑制のために利用
    const MINIMUM_VALUE = 0.00001; // 音量の最小値を指定
    // 音量計算処理を web audio api に登録する
    registerProcessor(
    "vumeter", // 登録名
    class extends AudioWorkletProcessor {
    constructor(options) {
    super();
    // 音声処理に利用する変数を定義
    this._volume = 0;
    this._updateIntervalInMS = options.processorOptions.updateIntervalInMS; // VUMeterNode クラスで指定できるユーザー任意のカスタムオプション
    this._nextUpdateFrame = this._updateIntervalInMS;
    // VUMeterNode クラスのオブジェクトからメッセージを受け取った時のイベントコールバック
    this.port.onmessage = (event) => {
    if (event.data.updateIntervalInMS)
    this._updateIntervalInMS = event.data.updateIntervalInMS; // 音量の更新頻度を変更
    };
    }
    get intervalInFrames() {
    return (this._updateIntervalInMS / 1000) * sampleRate;
    }
    process(inputs, outputs, parameters) {
    const input = inputs[0]; // 入力される音声(ソースノード)は1つだけと想定する
    if (input.length > 0) {
    const samples = input[0]; // 入力される音声のチャンネル数も1つだけ(モノラル)と想定する
    let sum = 0;
    let rms = 0;
    // 再生中のサンプルの平均を計算する
    // 一度に取得するサンプル数は128個
    for (let i = 0; i < samples.length; ++i) sum += samples[i] * samples[i];
    rms = Math.sqrt(sum / samples.length);
    this._volume = Math.max(rms, this._volume * SMOOTHING_FACTOR); // 急激な音量変化を抑制する
    // 音量の更新を VUMeterNode クラスに伝える
    this._nextUpdateFrame -= samples.length;
    if (this._nextUpdateFrame < 0) {
    this._nextUpdateFrame += this.intervalInFrames;
    this.port.postMessage({ volume: this._volume }); // メッセージの送信
    }
    }
    //VUMeterNode クラスのオブジェクトのライフタイム制御
    return this._volume >= MINIMUM_VALUE;
    }
    }
    );

    音声処理した結果(今回は計測した音量)をメインスレッドに伝えたい場合は、後述するVUMeterNodeクラスのオブジェクトにメッセージとして送信することで実現できます。

    逆にメッセージを受け取ることも可能です。このコードでは「音量の更新頻度の指示」をメインスレッドから受け取れるようになっています。

    VUMeterNode.js

    登録した音声処理を Web Audio API の機能の1つとして利用するためのクラスを作成します。

    AudioWorkletNode を継承することで作成できます。クラス名は「VUMeterNode」としました。

    親クラスのコンストラクターには、「AudioContext」「利用したい音声処理の登録名」「オプション」を引き渡しましょう。

    プロセッサーとのメッセージ送信・受信処理。メインスレッドで使用したい情報(音量 [dBu] など)のゲッターなどはここで作成しておきます。

    export default class VUMeterNode extends AudioWorkletNode {
    constructor(context, updateIntervalInMS) {
    super(context, "vumeter", {
    numberOfInputs: 1, // 受け付ける入力の数
    numberOfOutputs: 0, // 出力の数
    channelCount: 1, // 出力のチャンネル数。今回、出力はないので0以外なら何でもよい。
    processorOptions: {
    updateIntervalInMS: updateIntervalInMS || 16.67, // vuMeterProcessor に引き渡すユーザー任意のカスタムオプション
    },
    });
    // VUMeterNodeの内部状態
    this._updateIntervalInMS = updateIntervalInMS;
    this._volume = 0;
    // vuMeterProcessor からメッセージを受け取った時のイベントコールバック
    this.port.onmessage = (event) => {
    if (event.data.volume) this._volume = event.data.volume;
    };
    this.port.start();
    }
    get updateInterval() {
    return this._updateIntervalInMS;
    }
    set updateInterval(updateIntervalInMS) {
    this._updateIntervalInMS = updateIntervalInMS;
    this.port.postMessage({ updateIntervalInMS: updateIntervalInMS }); // メッセージの送信
    }
    volume() {
    // 現在の音量を dBu 単位に変換して返す関数。ゲッターでも良かった。
    return 20 * Math.log10(this._volume) + 18;
    }
    }

    main.js

    VUMeterNode クラスを利用した React コンポーネントを作成します。コンポーネント名は「VUMain」にしました。

    音声ファイルの指定や再生するまでの処理は、以前書いた記事「React.jsでWeb Audio APIを使ってみる」の内容を流用しています。

    異なる点は、VUMeterNode の取得部分とソースノードとの接続部分です。

    コードを見て分かるように Audio Worklet で作成した VUMeterNode は、取得する際に非同期の処理が必要になります。しかし、それ以外は通常の Web Audio API のAudioNode と同じように扱うことができます。

    今回は特にフィルターなどは追加せず、単純に sourceNode を入力先として接続してみます。

    import { useRef, useState, useEffect } from "react";
    import VUMeterNode from "./VUMeterNode";
    function VUMain() {
    const audioContext = useRef(null);
    const vuMeterNode = useRef(null);
    // 内部状態
    const [audioBuffer, setAudioBuffer] = useState(null);
    const [volume, setVolume] = useState(0);
    // 初期化
    useEffect(() => {
    audioContext.current = new AudioContext();
    const setVUMeterNode = async () => {
    // VUMeterNode クラスのオブジェクトを取得する
    const processorPass = "./audioWorklet/vuMeterProcessor.js";
    await audioContext.current.audioWorklet.addModule(processorPass);
    vuMeterNode.current = new VUMeterNode(audioContext.current, 25);
    // 音量の取得と更新を繰り返す
    const drawMeter = () => {
    const volume = vuMeterNode.current.volume();
    setVolume(volume);
    requestAnimationFrame(drawMeter);
    };
    drawMeter();
    };
    setVUMeterNode();
    }, []);
    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(audioContext.current.destination);
    sourceNode.connect(vuMeterNode.current);
    // 再生発火
    sourceNode.start();
    };
    return (
    <div>
    <div>
    <input type="file" onChange={handleChangeFile} />
    <button onClick={handleClickPlay}>再生</button>
    </div>
    <div>{"音量: " + volume + " [dBu]"}</div>
    </div>
    );
    }
    export { VUMain };

    以上になります。

    VUMain コンポーネントを index.js にインポートして配置すると、上で紹介したイメージ画像のようになります。

    とても高速に動作するので、更新頻度を指定する入力欄を作っても良かったかもしれません。

    まとめ

    今回は、VUメーターを Audio Worklet を利用して作成しました。

    自由度の高い機能なので、まだ全てを理解できたわけでないですが、これで Audio Worklet の大まかな使い方は確認できたかなと思っています。

    Web Audio API の通常機能では実現できない音声処理があれば、Audio Worklet を使ってみる選択肢を考えるようにしたいですね。

    VUメーターのようなリアルタイムな音声処理でなければ、再生前の audioBuffer を直接いじった方が簡単な場合もあるのでその点には注意したいです。