【Unity】リアルタイムビートトラッキングサンタクロースを作ろう


こちらは[Akatsuki Advent Calendar 2021](https://adventar.org/calendars/6566)の23日目の記事です．
# はじめに
クリスマスといえば，一年で最も「音に合わせて踊るおもちゃ」が魅力的に見える日ではないでしょうか．
子供のころはなかなか買ってもらえませんでしたが，もう社会人です，Unityで作りましょう．

音楽のBPMを手入力すると三行で記事が終わってしまうので，今回はリアルタイムで音楽のリズムを推定しダンスモーションを動的に合わせられるおもちゃに進化させようと思います．

こんな感じのものが出来上がります．
<iframe width="560" height="315" src="https://www.youtube.com/embed/b9cH_8p8Kbw" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

### 大まかなながれ
・Unity上で音源 or マイクから音声取得
・音声データからビートを検出
・リズムを推定し，Humanoidのダンスモーションの速度を合わせる
では作っていきましょう．

# リアルタイムリズム推定
### 音声からビートを検出
・ビートの音は低音が多い
・ビート時に音量の変化が激しい
という特徴より，音声にローパスを行い，直前のデータとの音量差分をとることでビートっぽいタイミングを抽出します
<img width="300" alt="audio" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/392712/ccb6df01-4bd6-9a47-6f6a-187b208fc818.png">
##### 具体的には
・マイク音声取得
・音声をFFT変換
・周波数の低い値の合計値を取る
・一個前の合計値をとの差分を出す

```
   [SerializeField] private AudioSource _micAudio;
    private float[] _spectrum = new float[256];
    private float _beforeVolume = 0;
    void Update()
    {
        if(!_micAudio.isPlaying) return;
        _micAudio.GetSpectrumData(_spectrum, 0, FFTWindow.Rectangular);
        var volume = _spectrum[0] + _spectrum[1] + _spectrum[2];
        BpmDetector(volume - _beforeVolume);
        _beforeVolume = volume;
    }

```




### ビートデータからリズム推定
BPM推定には，ビート音が周期的に現れる(周期分時系列をずらすとビートが一致する)性質から，[自己相関](http://frchick.blog129.fc2.com/blog-entry-725.html,"サイト")（時間をすこしずつずらして，一致するとこを探す手法）を用いることが多いようですが，
・今回音源データの取得間隔が一定とは限らない
・周期だけでなくビートタイミングも同時に求めたい
ので，矩形波へのマッチングで検出しようと思います．

#### 具体的には
下記のような周期やOffsetの異なる矩形波を大量に用意し，
それぞれと音量データを掛けて合計値を出します．
矩形とビートが一致しているほど合計値は高くなるため，
最も合計値が高い周期とOffsetにモーションを合わせると，音楽にあうようになります．
（裏拍表拍の判別は今回していませんが，どっちに合わせても大体ダンスはリズムに合ってるように見えます）

<img width="300" alt="step" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/392712/87e7f28c-ee26-050b-81b8-f2fe62fbcd3f.png">


```
        var nowTime = Time.time;
        int index = 0;
        for (int bpm = 70; bpm < 140; bpm++)
        {
            for (float offset = 0; offset < 60f / bpm; offset += 0.03f)
            {
                var swd = SquareWaveData(nowTime, 60f / bpm, offset);
                _squareVolumeSum[index] += swd * volume;
                index++;
            }
        }
        _sumCount++;
```
単純に足し合わせるだけだと，過去のデータに引っ張られ，リズムの変化に対応できないため，下記のように過去のデータほど影響が小さくなるように割合を掛けて足していき，それを類似度として用います．

```
if (_sumCount > 10){
        _sumCount = 0;
        index = 0;
        var max = float.MinValue;

        var answerBpm = 0;
        var answerOffset = 0f;
        for (int bpm = 70; bpm < 140; bpm++)
        {
            for (float offset = 0; offset < 60f / bpm; offset += 0.03f)
            {
                _similarities[index] = _similarities[index] * 0.99f + 0.01f * _squareVolumeSum[index];
                if (max < _similarities[index])
                {
                    max = _similarities[index];
                    answerBpm = bpm;
                    answerOffset = offset;
                }
                _squareVolumeSum[index] = 0;
                index++;
            }
        }
}
```
#### ダンスモーションがかくつかないように
上記より，BPMとOffsetがリアルタイムで推定されます．
BPMとオフセットがあると下記のようにアニメーションの速度と位置（オフセット）を決められます．

``` 
アニメーション速度　＝　アニメーションのリズム周期/推定ビートの間隔
アニメーションオフセット(0〜1)　＝　(（現在の時間ー推定オフセット）%推定ビートの間隔)/推定ビートの間隔
```
しかし，リアルタイムで推定される値はブレがある場合が多いので，そのまま代入するとモーションがかくつきます．

そこで今回は推定された矩形波のオフセットが既に設定されていたアニメーションオフセットと大きな誤差がない時のみ，アニメーションに値を代入すると言うことにしました．

```
        var animClip = _animator.GetCurrentAnimatorStateInfo(0);
        var beatTime = 60f / answerBpm;
        var nowAnimationTime = animClip.normalizedTime % 1;
        var answerAnimationOffset = (Time.time - answerOffset) % beatTime / beatTime;
        if (Mathf.Abs(answerAnimationOffset - nowAnimationTime) < 0.05f)
        {
            _animator.speed = _animationLength/ 2 / beatTime; // モーション中に2ビートあるため
            _animator.Play(animClip.shortNameHash, 0,answerAnimationOffset);
        }
```
これにより，スムーズにモーションを変えられる時だけ，ダンスを変えるようにします．
スムーズに変えられない際はちょっと音楽に合わないモーションになる可能性がありますが，今回はかくつかないことの方が重要だと感じたためこの実装にしました．

# まとめ
こんな感じで冒頭の動画のようなサンタのおもちゃが出来上がりました．
皆さんがリアルタイムビートトラッキングサンタクロースを作る際に参考になれば幸いです．
メリークリスマス


# 使用アセット
##### ダンスモーション
https://assetstore.unity.com/packages/3d/animations/dance-animations-free-161313
##### BGM
https://dova-s.jp/bgm/play10310.html
##### サンタモデル
https://assetstore.unity.com/packages/3d/characters/meshtint-free-santa-claus-106193
#### 背景
https://assetstore.unity.com/packages/3d/environments/minimalist-christmas-50549

#　コード全文
```
using System;
using System.Collections;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

public class SantaDance : MonoBehaviour
{
    
    [SerializeField] private TextMeshPro _tmp;
    [SerializeField] Animator _animator;
    [SerializeField] private AudioSource _micAudio;
    private bool _useMic = false;
    private float _animationLength = 1;
    void Start()
    {
        Init();
        if (_useMic)
        {
            string devName = Microphone.devices[0]; 
            int minFreq, maxFreq;
            Microphone.GetDeviceCaps(devName, out minFreq, out maxFreq); 
            _micAudio.loop = true; 
            _micAudio.clip = Microphone.Start(devName, true, 2, minFreq); 
            while (!(Microphone.GetPosition(devName) > 0)) { } 
            Microphone.GetPosition(null);
            _micAudio.Play();
        }
    }

    private Vector2Int _bpmMax = new Vector2Int(70, 140);
    void Init()
    {
        _animationLength = _animator.GetCurrentAnimatorStateInfo(0).length;
        for (int bpm = _bpmMax.x; bpm < _bpmMax.y; bpm++)
        {
            for (float offset = 0; offset < 60f / bpm; offset += 0.03f)
            {
                _sinDetectors.Add(0);
                _similarities.Add(0);
            }
        }
    }
    
    private float[] _spectrum = new float[256];
    private float _beforeVolume = 0;
    void Update()
    {
        if(!_micAudio.isPlaying) return;
        _micAudio.GetSpectrumData(_spectrum, 0, FFTWindow.Rectangular);
        var volume = _spectrum[0] + _spectrum[1] + _spectrum[2];
        BpmDetector(volume - _beforeVolume);
        _beforeVolume = volume;
    }
    float SquareWaveData(float sec, float span, float offset, bool digital = true)
    {
        var ans = Mathf.Sin((sec+offset)/span*2*Mathf.PI);
        if (ans > 0.9f) return 1;
        return 0;
    }
    
    private List<float> _sinDetectors = new List<float>();
    private List<float> _similarities = new List<float>();
    private int _sumCount = 0;
    void BpmDetector(float volume)
    {
        var nowTime = Time.time;
        int index = 0;
        for (int bpm = _bpmMax.x; bpm < _bpmMax.y; bpm++)
        {
            for (float offset = 0; offset < 60f / bpm; offset += 0.03f)
            {
                var sind = SquareWaveData(nowTime, 60f / bpm, offset);
                _similarities[index] += sind * volume;
                index++;
            }
        }

        _sumCount++;
        if(_sumCount < 20) return;
        _sumCount = 0;
        index = 0;
        var max = float.MinValue;
        
        var answerBpm = 0;
        var answerOffset = 0f;
        for (int bpm = 70; bpm < 140; bpm++)
        {
            for (float offset = 0; offset < 60f / bpm; offset += 0.03f)
            {
                _sinDetectors[index] = _sinDetectors[index]*0.9f + 0.1f*_similarities[index];
                if (max < _sinDetectors[index])
                {
                    max = _sinDetectors[index];
                    answerBpm = bpm;
                    answerOffset = offset;
                }
                _similarities[index] = 0;
                index++;
            }
        }
        
        var animClip = _animator.GetCurrentAnimatorStateInfo(0);
        var beatTime = 60f / answerBpm;
        var nowAnimationTime = animClip.normalizedTime % 1;
        var answerAnimationOffset = (Time.time - answerOffset) % beatTime / beatTime;
        if (Mathf.Abs(answerAnimationOffset - nowAnimationTime) < 0.1f)
        {
            _animator.speed = _animationLength/ 2 / beatTime; // 今回モーション中に2ビートあるため
            _animator.Play(animClip.shortNameHash, 0,answerAnimationOffset);
        }
        _tmp.text = "BPM " + answerBpm;
    }
}

```