凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity の WebGL で動的にサウンドを生成・再生する方法を調べてみた

はじめに

通常、Unity で動的にサウンドを生成したいときは、AudioClipCreatePCMReaderCallback を渡すか、MonoBehaviourOnAudioFilterRead 中でバッファをセットすることで可能です。

docs.unity3d.com

docs.unity3d.com

かなり古い記事ですが、以前解説も書きました(一部有料機能と述べているところは現状はすべて無料です)

tips.hecomi.com

AudioClipSetData して再生することも出来ますが、初期化時ではなく毎フレームデータを与えたい場合はこれは利用できません。正確にはセットして再生はできるのですが、サウンドの読み取り・再生自体はサウンドのスレッドで行われているため、Update などのメインスレッドから与えるとうまく再生されない(途切れたりする)からです。上記の機能を利用することでうまく再生できるようになります。

しかしながら WebGL 書き出しの場合、JavaScript のスレッドの制約があるからか、これらの機能は残念ながら利用できません。

docs.unity3d.com

Unity WebGL partially supports AudioClip.Create. Browsers don’t support dynamic streaming, so to use AudioClip.Create, set the Stream to false.

本記事では、それでも WebGL ビルドで動的にサウンドを生成・再生したい人向けの記事になります。

サンプル

github.com

Start を押すと声が再生され、ゲージをスライドするとボリュームが変わります。内部的には毎フレーム Unity 側から jslib を経由してデータを渡し、それを Web Audio API で再生しています。

構成の概要

再生には Web Audio API を使います。

developer.mozilla.org

本記事では詳しい解説はしませんが...、シンプルな audio タグによる音声再生とは異なり、音を生成したり、エフェクトを掛けたり...、といった柔軟な操作が可能な API 体系です。

JavaScript と Unity の橋渡しには jslib の仕組みを利用します。

docs.unity3d.com

これをもとに次のようなサンプルを作ってみます。

  • Unity から jslib 経由で毎フレーム float[]JavaScript 側へ送る
  • JavaScript 側(jslib 内部)で Web Audio API を使い送られてきた float[] バッファを再生
  • 送るバッファはとりあえずは AudioClip を事前処理して float[] にしたものを小分けにして送る

プロジェクト構成は以下のようになります。

具体的な処理

jslib

次のように、初期化関数(Init)と再生関数(Play)を用意しておきます。おまけでボリューム変更(SetVolume)も用意しています。

const AudioStreamPlugin =
{
    ctx: null,
    sampleRate: 44100,
    initDelay: 0.5,
    scheduledTime: 0.0,
    volume: 1.0,

    Init: function(sampleRate, initDelay)
    {
        if (this.ctx) return;

        this.ctx = new window.AudioContext();
        this.sampleRate = sampleRate;
        this.initDelay = initDelay;

        console.log("Init: ", sampleRate, initDelay);
    },

    Play: function(offset, size) {
        if (!this.ctx) return;

        const arrayF32 = new Float32Array(size);
        for (let i = 0; i < size; ++i) {
            arrayF32[i] = HEAPF32[(offset >> 2) + i];
        }

        const gain = this.ctx.createGain();
        if (isFinite(this.volume)) {
            gain.gain.value = this.volume;
        }
        gain.connect(this.ctx.destination);

        const buf = this.ctx.createBuffer(1, size, sampleRate);
        buf.getChannelData(0).set(arrayF32);

        const src = this.ctx.createBufferSource();
        src.buffer = buf;
        src.connect(gain);

        const t = this.ctx.currentTime;
        if (t < this.scheduledTime) {
            this.scheduledTime += buf.duration;
        } else {
            this.scheduledTime = t + buf.duration + initDelay;
        }

        src.start(this.scheduledTime);

        console.log("Play", this.scheduledTime);
    },

    SetVolume: function(volume) {
        this.volume = volume;
    }
};

mergeInto(LibraryManager.library, AudioStreamPlugin);

Web Audio API の流れとしては、まずは初期化時にコンテキストを生成しておきます。Play() は Unity からは float[] を渡すのですが、こちらでは offset が渡ってきて、それを使って HEAPF32 から取り出す形になります。これを createBufferSource() して作成したバッファにセットし、ソースを作成します。Web Audio APIconnect でノードをつないでいく流れになるので、ソース → ゲイン(ボリューム調整)→ 出力、とつないで、最後にソースをスタートすることで再生が出来ます。再生は毎フレーム、チャンクごとに行い、それぞれのバッファの大きさを見て再生時間を計算、scheduledTime でディレイを計算して遅延再生することで、それぞれのチャンクの再生がいい感じに繋がります。このとき、全体を少し遅らせて(initDelay)再生することでバッファ時間を確保しています。これによって少し音声の送信にズレが生じても、その分を吸収してプチプチ途切れず再生できます。

こちらのソースがとても参考になりました。

Unity

Unity 側では、この jslib を使う関数を用意します。

using UnityEngine;
using System.Runtime.InteropServices;

namespace WebGLAudioStream
{

static public class Lib
{
#if UNITY_EDITOR
    public static void Init(int sampleRate, float initDelay)
    {
        Debug.Log($"Init({sampleRate}, {initDelay})");
    }
    public static void Play(float[] array, int size)
    {
        Debug.Log($"Play({array}, {size})");
    }
    public static void SetVolume(float volume)
    {
        Debug.Log($"SetVolume({volume})");
    }
#else
    [DllImport("__Internal")]
    public static extern void Init(int sampleRate, float initDelay);
    [DllImport("__Internal")]
    public static extern void Play(float[] array, int size);
    [DllImport("__Internal")]
    public static extern void SetVolume(float volume);
#endif
}

}

[DllImport("__Internal")]JavaScript 側の関数をそのまま呼び出すことが出来ます。さて、これを次のように利用します。

using UnityEngine;
using System;

namespace WebGLAudioStream
{

public class AudioBufferSender : MonoBehaviour
{
    public AudioData audioData;
    public float initDelay = 0.5f;
    public int chunkSize = 2048;
    float[] _buf = null;
    int _clipPos = 0;
    int _bufPos = 0;

    public void Init()
    {
        if (audioData == null || !audioData.clip) return;
        _buf = new float[chunkSize];
        Lib.Init(audioData.clip.frequency, initDelay);
    }

    public void SetVolume(float volume)
    {
        Lib.SetVolume(volume);
    }

    void Update()
    {
        if (audioData == null) return;

        var clip = audioData.clip;
        if (clip == null) return;

        if (_buf == null) return;

        int n = (int)(clip.frequency * Time.unscaledDeltaTime);
        if (n > clip.samples) return;
        if (_clipPos + n >= clip.samples) n = clip.samples - _clipPos;
        var data = new float[n];

        System.Array.Copy(audioData.data, _clipPos, data, 0, n);
        _clipPos += n;
        if (_clipPos >= clip.samples) _clipPos = 0;

        var len = n;
        if (_bufPos + len >= chunkSize) len = chunkSize - _bufPos;
        Array.Copy(data, 0, _buf, _bufPos, len);
        _bufPos += len;

        if (_bufPos >= chunkSize)
        {
            Lib.Play(_buf, chunkSize);
            Array.Clear(_buf, 0, chunkSize);
            _bufPos = 0;

            var rest = n - len;
            if (rest > 0)
            {
                Array.Copy(data, len - 1, _buf, 0, rest);
                _bufPos += rest;
            }
        }
    }
}

}

AudioData から 1 フレーム分のオーディオのバッファを取り出し、これを float[] につめて Play() を呼んでいます(ループ再生するようにしています)。ここでいう AudioData は予め AudioClip から GetData() して float[] を格納しておいた ScriptableObject です。WebGL では AudioClip.GetData() が出来なかったのでサンプルのために少し回りくどいことをしています。実際は数式を書いて生成したり、ストリーミングで渡ってきたデータを入れたり、用途に応じて変更してください。なお、AudioData は次のような感じです。

using UnityEngine;

namespace WebGLAudioStream
{

[CreateAssetMenu(menuName = "AudioData")]
public class AudioData : ScriptableObject
{
    public AudioClip clip;
    public float[] data;

    [ContextMenu("Decode")]
    public void Decode()
    {
        if (!clip) return;
        data = new float[clip.samples];
        clip.GetData(data, 0);
    }
}

}

おわりに

Unity 5 のときに頑張って Application.ExternalCall() したり、長い WebGL ビルドを待ったり、といった時以来に WebGL ビルドを触りましたが、現在はビルドも早く、jslib も作成が簡単でかなり開発しやすくなっていました。昔の記事はこちら(なんと執筆時から約 8 年...):

tips.hecomi.com

また、Web 周りも Web Audio API に限らず便利な API が色々とありますので、アイディア次第で面白いものが作れそうですね。例えば Web Bluetooth で接続したデバイスの情報を Unity ベースで作成した WebGL でグリグリ動かす、とかも比較的簡単にできそうです。