AudioClipなしで音が鳴る動的波形生成

こんにちは。技術部平山です。

今日は、Unityで実行時に音声波形を作って鳴らしてみました。 残念ながら99.9%趣味です。 楽曲や効果音は事前に作ったものを鳴らすのが普通で、 CPUで波形を生成しながら音を鳴らす、なんていうことはまずないと思います。

なお、使っているAPIは本来波形に効果を加えるためのもので、 AudioClipから出てきた音に山彦のような効果をつける ようなことができます。 ただし、用意されたフィルタで済むなら、 その方が電気も食わず性能への影響も軽く済むと思いますので、 それで済まない素敵な用途を見つけたいですね。

もう一つ、波形を分析して、それに合わせてキャラを口パクさせたい、というお話も見つけました。 なるほど、音を変更あるいは生成するのでなく、 音に合わせて何かするために波形を得る、という用途もありそうですね。

サンプルコード

コードはgithubにサンプルプロジェクトの形で置いてあります。 できればWebGLビルドを用意してたいのですが、 残念ながらWebGLでは今回やる手法が使えず、 プロジェクトをダウンロードしてエディタ実行する他あありません。 実行して"awsedftgyhujkolp"のどれかのキーを押すと音が鳴ります。

MonoBehaviour.OnAudioFilterRead

使うのはMonoBehaviour.OnAudioFilterRead()です。 AudioSourceがついているGameObjectに、OnAudioFilterReadを実装したコンポーネントをつければ、そのAudioSourceから出てきた音に独自にフィルタをかけられます。

例えば、

void OnAudioFilterRead(float[] data, int channels)
{
    for (int i = 0; i < sampleCount; i++)
    {
        data[i] = data[i] * 0.5f;
    }
}

こんな感じにすれば、信号を半分にできます。

さて、現実にこれを製品で使うとしたらどんな時でしょう? 公式マニュアル にある通り、高音を減らす(LowPass)、低音を減らす(HighPass)、 エコーや山彦をかける(Echo)、歪ませる(Distortion)、 残響をつける(Reverb)、コーラスをかける(chorus)、 といったあたりは標準で用意されています。 一律に大きくしたり小さくするならボリュームをいじるべきで、 1秒間あたり4万あるいは8万もfloatが並ぶ波形データを CPUで処理する価値がある計算はなかなか見つかりません。

実装の注意点

この関数は別スレッドで呼ばれるので、UnityEngineの関数は使えません。 とはいえ、Mathfは大丈夫でした。SinやCosで正弦波を作ることはできるようです。

以下趣味

波形生成

今回のサンプルは、ピアノっぽい何かになっています。 キーボードを鍵盤に見立てていて、押すと音が鳴ります。

実装としては、 それぞれのキーに対応したバネダンパ を用意し、それが生成する波形を合計することで、 88個の正弦波を一つのAudioSourceから鳴らしています。

struct Oscillator
{
    public void Attack(float strength)
    {
        var pulse = strength * Mathf.Sqrt(Stiffness);
        velocity += pulse;
    }
    public float Update(float deltaTime)
    {
        velocity -= velocity * Damping * deltaTime;
        velocity -= position * Stiffness * deltaTime;
        position += velocity * deltaTime;
        return position;
    }
    float position;
    float velocity;
    public float Stiffness { set; private get; }
    public float Damping { set; private get; }
}

調和振動子はこのOscillator型で表現されていて、 位置と速度を持ち、Update()でオイラー積分していきます。

以前書いた記事 で、バネダンパで三角関数を表現できることを お伝えしましたが、今回はその応用です。 Sin()やCos()なしで三角関数が計算できます。

あとは、それぞれの正弦波を足し合わせたものを、OnAudioFilterRead() の引数で受け取った配列につっこみます。

void OnAudioFilterRead(float[] data, int channels)
{
    int sampleCount = data.Length / channels;
    for (int i = 0; i < sampleCount; i++) // 必要なサンプル数だけ
    {
        float v = UpdateOsccilators(); // 全振動子の積分を更新して
        for (int c = 0; c < channels; c++) // チャネル分コピー
        {
            data[(i * channels) + c] = v;
        }
    }
}

float UpdateOsccilators()
{
    var v = 0f;
    for (int i = 0; i < oscillators.Length; i++)
    {
        v += oscillators[i].Update(deltaTime);
    }
    return v;
}

バネダンパはピアノのキーと同じ88個用意していて、 1秒間にステレオなら44100*2=88200要素の配列を出力しますから、 1秒あたり88200*88=7761600回もバネダンパの更新を行っています。ひどい実装ですね。 そもそも押せるキーが16個しかないので、88個用意してるのは完全に無駄です。 まあ遊びですから。

バネダンパのパラメータ

それぞれのバネダンパは、ピアノのキーに対応しているので、 周波数が決まっています。

例えば100Hzの音であれば、1秒間に100回振動するようなパラメータにする必要があります。 バネダンパのパラメータはバネ定数(Stiffness)と、減衰定数(Damping)の二つですが、 周波数に関係するのはバネ定数の方です。 これを適切に設定すると、バネダンパの速度にパルスを与えた時に、 いい感じの振動数で振動します。

さて、減衰を無視した場合、バネダンパは調和振動子になります。 面倒くさいので位相を無視してsinだけにすれば、

p = sin(sqrt(k) * t);

が調和振動子の位置を計算するコードになります。kはバネ定数です。 面倒なので振幅も質量も1としました。

sqrt(k) * tが2pi増えるとSinが一周しますから、1秒あたりの回転数は、 sqrt(k) / (2 * pi)です。1秒あたりの回転数=音の周波数ですから、 これが100Hzなのであれば、k = (2 * pi * 100)^2となります。 かくして、それぞれのキーの周波数がわかればバネ定数が求まるわけです。

さて、ピアノの一番下のキーは、A(ラ)です。 時報の440Hzのラよりも4オクターブ下です。 1オクターブ下がるごとに周波数は半分になりますから、 2の4乗=16で割って、27.5Hzとなります。 あとは、「1半音上がる度に2の12乗根倍」していけば終わりです。

var step = Mathf.Pow(2f, 1f / 12f); // 2の12乗根
var f = 440f / 16f;
for (int i = 0; i < oscillators.Length; i++)
{
    var w = f * 2f * Mathf.PI;
    var stiffness = w * w;
    oscillators[i].Stiffness = stiffness;
    f *= step;
}

N乗根は1/N乗ですから、Powで求められます。 真面目に書くなら、演算誤差を防ぐために、乗算を累積させない方がいいでしょうね。

パルス

キーを叩いた時には、ハンマーでピアノの弦を叩くように、 バネダンパの速度を瞬間的に変化させます。 どれくらい変化させれば望んだ振幅で振動するでしょうか?

これは調和振動子の式を微分すればわかります。

v = sqrt(k) * -cos(sqrt(k) * t);

sqrt(k)が掛かっていますから、速度の振動は-sqrt(k)から+sqrt(k)まで になります。 0.5の幅で振動してほしければ、sqrt(k)*0.5の速度を与えて振動させれば良い、 ということです。

public void Attack(float strength)
{
    var pulse = strength * Mathf.Sqrt(Stiffness);
    velocity += pulse;
}

なお、同じ振幅でも低い音は聴こえにくいので、同じ音量には感じません。 このあたりは産総研のサイト が参考になるかと思います。 30Hzの音は120Hzの音に比べて1/100くらいにしか聴こえないので、 実質聴こえません。ピアノではガーンと鳴りますが、聴こえているのはほぼ倍音成分です。 このアプリにも倍音を入れればあんな感じに鳴るようになると思います。

今後の展開

遊びなので今後も何もないんですが、 ヒマがあればやりたいことがもう少しあります。

  • 音色の合成
  • 揺らし
  • スマホ対応
  • 共鳴

音色の合成

音色を変えるには、正弦波以外の波形にすれば良いわけですが、 矩形波や三角波のような「いかにも電子」な音はレトロ感が出すぎます。 一つの音にバネダンパを複数使えば音色を合成できるわけですから、 それがいろいろできるおもちゃにするのが楽しそうです。

揺らし

ドラッグか何かの操作で音に表情をつけられるといいですね。 スマホなら加速度センサーを加味すると面白そうです。 バネ定数を毎フレームわずかに変化させて、音の高さを揺らします。

スマホ対応

現状のUIではキーボードがないと何もできないので、 鍵盤のUIを作ってスマホで動くようにしたいものです。 なにせおもちゃなので時間をかけられませんでしたし、 スマホのビルドをここに置いてもインストールしてくれる人なんていなさそうなので、 今回はそこまではしませんでした。 WebGLでOnAudioFilterReadが動けばなあ...

共鳴

同じ長さの弦を2本置いて、片方を振動させると、もう片方も振動し始めます。 他の弦から出た振動を受け取って自分も揺れる、というようなことをすると、 音が複雑に絡みあって面白そうです。しかし一気に計算量が膨れ上がるので、 シェーダを使ってやりたいところですね。 コンピュートシェーダが使えるか試してみたいものです。

終わりに

私はゲームのサウンド処理は素人なのですが、 前々から「もっと動的計算を入れたい」と思っていました。 録音済みの音を垂れ流して終わり、ではインタラクティブ性が薄く、 飽きやすくなります。エフェクトをかける、というのは有効な手段ですが、 もっと進めて、「曲の切れ目が全くないのに場面に応じて曲調が変わる」 なんてことができたらなあと思います(やっている作品はあるので何も新しくないのですが)。

いっそ、正弦波の動的合成だけでゲームを作ってみる、 なんてのは面白いかもしれませんね。 売り物にはならないでしょうけれども。

おまけ

今回は命名規則を変えました。弊社ゲーム部門では、

  • private変数は_から初めてcamelCase
  • プロパティ名はUnity同様camelCase

が多いのですが、今回は

  • private変数は_なしのcamelCase
  • プロパティ名はC#標準に合わせてPascalCase

としました。外に出すコードなら MSの公式文書 に合わせた方がいいだろう、という判断です。 上記文書ではprivateについては何も言っていないのですが、 MS製のholoLensのサンプル ではprivateがcamelCaseだったので、それに合わせました。

でもローカル変数とprivateフィールドの区別がつかないのに慣れないですし、 たまにthisが必要になるので、面倒くさくなって戻すかもしれません。