Web Audio API – AudioWorklet で遊ぶ –

この記事は WebAudio/WebMIDI API Advent Calendar 2017 Advent Calendar 2017 の 24 日目です.
ということで, 久々の Web Audio API ネタです.

1. Overview

Chrome 64 から実験的フラグつきでついに AudioWorklet が利用可能になります. この記事では, AudioWorklet の概要を解説し, 少し実践的なサンプルをとおして AudioWorklet に慣れることを目的とします.

What is AudioWorklet ?

Web Audio API が定義する標準のノード (GainNode, DelayNode, BiquadFilterNode … など) の組み合わせのみでは困難な音響処理 (ピッチシフターやノイズサプレッサなど) を直接サウンドデータにアクセスして演算することで実装するためのオブジェクト群 (AudioWorklet, AudioWorkletNode, AudioWorkletGlobalScope, AudioWorkletProcessor) です.

The Problems of ScriptProcessorNode

Web Audio API が実装された当初から, Web Audio API を利用している方はご存知だと思いますが, 直接サウンドデータにアクセスして演算するという処理は ScriptProcessorNode の役割でした. では, なぜ AudioWorklet に役割が置き換わるのでしょうか ? ScriptProcessorNode には実装当初から常に 2 つの問題を抱えていました.

  • イベントハンドラで非同期に実行されるので, レイテンシ (遅延) に問題を引き起こす
  • メインスレッドで実行されるので, UI や再生されるサウンドに問題を引き起こす

AudioWorklet では, これらの問題を解決するために, メインスレッドとは別に, オーディオスレッドで動作するように仕様策定され, そして, (Chrome 64 で) 実装されました.

2. AudioWorklet samples

とりあえず, 動作とコードをひととおり確認したい方は, GitHub Pages にアップしているのでご利用ください. 以下のセクションでは, 5 つのサンプルのうち 3 つにフォーカスして解説したいと思います.

Bypass

まずは, ウォーミングアップです. 特に意味のない処理ですが, 入力されたオシレーターをそのまま出力するだけです.

main-scripts/bypass.js

'use strict';

document.addEventListener('DOMContentLoaded', () => {
    const context = new AudioContext();

    let oscillator = null;

    context.audioWorklet.addModule('./worklet-scripts/bypass.js').then(() => {
        const bypass = new AudioWorkletNode(context, 'bypass');

        document.querySelector('button').addEventListener('click', event => {
            const button = event.currentTarget;

            if (button.textContent === 'START') {
                oscillator = context.createOscillator();

                oscillator.connect(bypass);
                bypass.connect(context.destination);

                oscillator.start(0);

                button.textContent = 'STOP';
            } else {
                oscillator.stop(0);

                button.textContent = 'START';
            }
        }, false);
    });
}, false);

まず, AudioContext#audioWorklet#addModule メソッドで, オーディオスレッドで実行させるスクリプトを読み込みます. そして, このメソッドは Promise を返すので, then に後続の処理を記述します. このあたりの処理は, Service Workers に近い処理となっています.

次に, AudioWorkletNode インスタンスを生成します. 第 1 引数には, AudioContext インスタンスを, 第 2 引数には任意の文字列を指定しますが, この文字列は, オーディオスレッドで実行させるスクリプトで実行する registerProcessor と合わせる必要があります.

そして, AudioWorkletNode は AudioNode を継承しているので, 通常のノードと同じように接続するだけです.

メインスレッドでの基本処理はこの 3 つです. このあとのサンプルでも同じです. 結構簡単なのではないでしょうか ?

worklet-scripts/bypass.js

'use strict';

class Bypass extends AudioWorkletProcessor {
    constructor() {
        super();
    }

    process(inputs, outputs) {
        const input  = inputs[0];
        const output = outputs[0];

        for (let channel = 0, numberOfChannels = output.length; channel < numberOfChannels; channel++) {
            output[channel].set(input[channel]);
        }

        return true;
    }
}

registerProcessor('bypass', Bypass);

まず, AudioWorkletProcessor を継承させたクラスを実装する必要があります.

次に, process メソッドで, 実際にサウンドデータにアクセスする処理を記述します. これは, ScriptProcessorNode における onaudioprocess イベントハンドラに相当する部分です. 引数には, 入力データにアクセスするための inputs と, 出力するための outputs 引数が自動で渡されます. const input = inputs[0];, const output = outputs[0]; は, イディオムのようなものと覚えてしまって大丈夫でしょう (ただし, 入力データがない場合は注意してください). 今回は, 入力データをそのまま出力させるだけなので, チャンネルごとに Float32Array を取得して, Float32Array#set するだけです.

return true; を指定することで, process メソッドが繰り返し呼び出されます.

最後に, AudioWorkletGlobalScope#registerProcessor メソッドを呼び出します. 第 1 引数には, メインスレッドで AudioWorkletNode インスタンスを生成する場合に指定した文字列を, 第 2 引数には, 実装したクラスを指定します.

White Noise

このままでは, おもしろくありませんから, Web Audio API が定義する標準のノードでは実装できないけど, 簡単な音響処理として, ホワイトノイズを生成してみましょう.

main-scripts/white-noise.js

'use strict';

document.addEventListener('DOMContentLoaded', () => {
    const context = new AudioContext();

    context.audioWorklet.addModule('./worklet-scripts/white-noise.js').then(() => {
        const noiseGenerator = new AudioWorkletNode(context, 'white-noise');

        document.querySelector('button').addEventListener('click', event => {
            const button = event.currentTarget;

            if (button.textContent === 'START') {
                noiseGenerator.connect(context.destination);

                button.textContent = 'STOP';
            } else {
                noiseGenerator.disconnect(0);

                button.textContent = 'START';
            }
        }, false);
    });
}, false);

メインスレッドは, 先ほどのセクションで述べたように, 重要なのは 3 つの処理, すなわち,

  1. オーディオスレッドで実行するスクリプトの登録
  2. AudioWorkletNode インスタンスの生成
  3. AudioWorkletNode インスタンスの接続

worklet-scripts/white-noise.js

'use strict';

class WhiteNoise extends AudioWorkletProcessor {
    constructor() {
        super();
    }

    process(inputs, outputs) {
        const output = outputs[0];

        for (let channel = 0, numberOfChannels = output.length; channel < numberOfChannels; channel++) {
            const outputChannel = output[channel];

            for (let i = 0, len = outputChannel.length; i < len; i++) {
                outputChannel[i] = 2 * (Math.random() - 0.5);
            }
        }

        return true;
    }
}

registerProcessor('white-noise', WhiteNoise);

process メソッドを除けば, ほとんど Bypass のサンプルと変わりません. つまり, AudioWorklet の処理の本質はそれほど難しくはないのです. process メソッドも, 入力がないのと, Float32Array の要素を 1 つずつ生成していく点を除けば Bypass のサンプルと同じです. あとは, 結局のところ, ホワイトノイズの生成処理を実装できるかどうかです. 少しずつ慣れてきましたか ? 最後は, ちょっと本格的なエフェクトを実装してみましょう.

Vocal Canceler

ボーカルキャンセラを実装するには, もう 1 つだけ AudioWorklet に関する知識を追加する必要があります. それは, メインスレッドとオーディオスレッド間でのメッセージングです. もっとも, WebWorkers や Service Workers でも利用されている, postMessage メソッドと onmessage イベントハンドラを利用するだけなので, それほど難しくはありません. 実際に, ボーカルキャンセラのメインスレッドとオーディオスレッドのスクリプトを見てみましょう.

main-scripts/vocal-canceler.js

'use strict';

document.addEventListener('DOMContentLoaded', () => {
    const context = new AudioContext();

    let source = null;

    context.audioWorklet.addModule('./worklet-scripts/vocal-canceler.js').then(() => {
        const vocalCanceler = new AudioWorkletNode(context, 'vocal-canceler');

        document.querySelector('[type="file"]').addEventListener('change', event => {
            const file = event.target.files[0];

            if (file && file.type.includes('audio')) {
                const objectURL = window.URL.createObjectURL(file);

                const audioElement = document.querySelector('audio');

                audioElement.src = objectURL;

                audioElement.addEventListener('loadstart', () => {
                    if (source === null) {
                        source = context.createMediaElementSource(audioElement);
                    }

                    source.connect(vocalCanceler);
                    vocalCanceler.connect(context.destination);

                    audioElement.play(0);
                }, false);
            }
        }, false);

        document.querySelector('[type="range"]').addEventListener('input', event => {
            vocalCanceler.port.postMessage(event.currentTarget.valueAsNumber);
        }, false);
    });
}, false);

メインスレッドの基本の 3 つの処理は変わりません. 注目していただきたいのは, AudioWorkletNode#MessagePort#postMessage メソッドです. これによって, ボーカルキャンセラに必要なパラメータをオーディオスレッドに送信しています.

worklet-scripts/vocal-canceler.js

'use strict';

class VocalCanceler extends AudioWorkletProcessor {
    constructor() {
        super();

        this.depth = 0;

        this.port.onmessage = event => {
            this.depth = event.data;
        };
    }

    process(inputs, outputs) {
        const input  = inputs[0];
        const output = outputs[0];

        const numberOfChannels = output.length;

        if (numberOfChannels === 2) {
            const inputLs  = input[0];
            const inputRs  = input[1];
            const outputLs = output[0];
            const outputRs = output[1];

            for (let i = 0, len = outputLs.length; i < len; i++) {
                outputLs[i] = inputLs[i] - (this.depth * inputRs[i]);
                outputRs[i] = inputRs[i] - (this.depth * inputLs[i]);
            }
        } else {
            output[0].set(input[0]);
        }

        return true;
    }
}

registerProcessor('vocal-canceler', VocalCanceler);

オーディオスレッド側では, AudioWorkletProcessor#MessagePort#onmessage イベントハンドラでメインスレッドから送信されたデータを受診します. データは, イベントオブジェクトの data プロパティに格納されています.

今回の例では, メインスレッドからオーディオスレッドへのメッセージングでしたが, 逆も可能で, つまり双方向にメッセージをやりとりすることが可能です.

3. Conclusion

いかがでしたでしょうか ? 少し駆け足になってしまった感もありますが, おそらく仕様などから想像していたよりも簡単だったのではないのでしょうか ? 結局のところ, 本質は ScriptProcessorNode と同じく, 音響処理に関する知識やそれを実装に落とし込めるかの力量にかかっているように思います.

Chrome 64 でもフラグが必要なので, 実際のプロダクトでは, クロスブラウザ対応などを考慮すると ScriptProcessorNode がしばらく君臨するでしょうが, 来たるべきときに備えて, AudioWorklet にも慣れておくといいことがあるかもしれません !

AudioWorklet W3C

コメントを残す