技術的なやつ

技術的なやつ

5.7 CROSSO的採点機

  1. Unityを用いる
    1. C#について少し復習する
    2. Unityの2D描画について簡単に学ぶ
  2. Unityの音声制御
    1. MIDIを再生させる手段を見つける
    2. 音声のマイク入力手段を見つける
  3. 入力音声の解析
    1. UnityのFFTライブラリを触ってみる
    2. FFTされたデータから音程を確定させるアルゴリズムを考える
  4. MIDIの解析
    1. 第1トラックの楽譜を解析する
    2. MIDIの再生箇所を解析する手段を見つける
  5. 採点のための解析
    1. 全体の点数計算アルゴリズムを考える
    2. ビブラート判定アルゴリズムを考える
    3. しゃくり判定アルゴリズムを考える
    4. タイミング判定アルゴリズムを考える
    5. なめらかさ判定アルゴリズムを考える

f:id:ibako31:20140303005448j:plain

CROSSO風な採点機が完成した。

配点

CROSSOなので、こんな感じ。

  • 音程…90
    • ただし、線形配点ではない
  • タイミング…2
  • なめらかさ…1
  • ビブラート…7
  • しゃくり…3

音程

瞬間瞬間で、歌唱した数値音階と、その時出すべき数値音階を渡して評価する。
ぴったりじゃなくても部分点が入るようにしている。

タイミング

1つのノートが終わるごとに評価する。
4分音符以下の長さの音程バーのどこかで、正しい音程を発していればOK。
音程バーにはそれぞれタグが付いており、タグの数値はx分音符を意味する。たとえば、四分音符なら4、全音符なら1、タイで2小節にまたがる全音符・全音符なら0.5。

なめらかさ

2つのノートが終わるごとに評価する(事実的に1つのノートが終わるごとに評価する)。
2つのノートの間で何らかの音を発していればOK。階段上に音が続く部分では特に大きく評価する。

ビブラート

1つのノートが終わるごとに評価する。
そのノート中に波が2つ以上連続して存在すればOK。
sin的な波と-sin的な波の2つを想定する。評価ポイントは、sin(x)でいうとx=0,PI/2,PI,3PI/2,2PI。横幅および縦幅に制限を設け、それを満たしていればOKとする。

しゃくり

1つのノートが終わるごとに評価する。
ノート中に、「本来の音程より低い音程をm秒連続で発し、その後正しい音程をn秒保った」場合OK。

コード

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace CROSSO
{
	class Evaluator
	{
        private int all_time;  // 歌唱しなければならない全時間(10ms)
        private int time;  // 歌唱時間(10ms)
        class Interval
        {
            private int interval_score;  // 音程点。満点なら1回の評価につき5点入る
            private int interval_num;  // 評価した音程の数
            
            // 初期化処理
            public Interval()
            {
                interval_score = 0;
                interval_num = 0;
            }

            // 音程を1回評価する
            // scale: 出した数値音階、best_scale: 出すべき数値音階
            public void Evaluate(float scale, float best_scale)
            {
                float dif = Math.Abs(scale - best_scale);
                interval_num += 1;
                if (scale == 0.0f)
                {
                    interval_score += 2;
                }
                else if (dif < 0.7f)
                {
                    interval_score += 5;
                }
                else if (dif < 1.0f)
                {
                    interval_score += 4;
                }
                else if (dif < 1.5f)
                {
                    interval_score += 3;
                }
                else if (dif < 2.0f)
                {
                    interval_score += 2;
                }
                else
                {
                    interval_score += 1;
                }
            }

            // 音程の評価値を返す
            public float GetScore()
            {
                if (interval_num == 0)
                {
                    return 0.0f;
                }
                else
                {
                    return 100.0f * (interval_score / 5.0f / interval_num);
                }
            }
        }


        class Timing
        {
            private int timing_score;  // 評価点。0か1か
            private int timing_num;  // 評価したタイミングの数
            
            // 初期化処理
            public Timing()
            {
                timing_score = 0;
                timing_num = 0;
            }

            // タイミングを1回評価する
            // scale: 出した数値音階、best_scale: 出すべき数値音階、start_index: 始まりのインデックス番号、index_size: その音符のインデックス長さ
            public void Evaluate(float[] scale, float best_scale, int start_index, int index_size)
            {
                timing_num += 1;
                for (int i = 0; i < index_size; i++)
                {
                    float dif = Math.Abs(scale[(start_index + i) % scale.Length] - best_scale);
                    if (scale[i] != 0.0f)
                    {
                        if (dif < 0.5f)
                        {
                            timing_score += 1;
                            break;
                        }
                    }
                }
            }

            // タイミングの評価値を返す
            public float GetScore()
            {
                if (timing_num == 0)
                {
                    return 0.0f;
                }
                else
                {
                    return 100.0f * (timing_score / 1.0f / timing_num);
                }
            }
        }


        class Smoothness
        {
            private int smoothness_score;  // 評価点。0~5
            private int smoothness_num;  // 評価したなめらかさの数
        
            // 初期化処理
            public Smoothness()
            {
                smoothness_score = 0;
                smoothness_num = 0;
            }

            // なめらかさを1回評価する(合わせて全音符以下の長さの、2つ分のノートを評価する)
            // scale: 出した数値音階、best_scale: 出すべき数値音階(2つ)、start_index: 始まりのインデックス番号、index_size: インデックス長さ、border_index: 境界のインデックス
            public void Evaluate(float[] scale, float[] best_scale, int start_index, int index_size, int border_index)
            {
                smoothness_num += 1;
                for (int i = 0; i < index_size; i++)
                {
                    int index = (start_index + i) % scale.Length;
                    if (index == border_index % scale.Length)
                    {
                        if (scale[index] != 0.0f)
                        {
                            float dif = Math.Abs(best_scale[0] - best_scale[1]);
                            if (dif == 0.0f) smoothness_score += 1;
                            if (dif <= 2.0f) smoothness_score += 5;
                            else if (dif <= 5.0f) smoothness_score += 3;
                            else smoothness_score += 1;
                        }
                        else
                        {
                            smoothness_score += 0;
                        }
                        break;
                    }
                }
            }

            // なめらかさの評価値を返す
            public float GetScore()
            {
                if (smoothness_num == 0)
                {
                    return 0.0f;
                }
                else
                {
                    return 100.0f * (smoothness_score / 3.0f / smoothness_num);
                }
            }
        }


        class Vibrato
        {
            private int vibrato_score;  // 評価点。感知できたノート数
            private int vibrato_num;  // 評価したノートの数
            private int time;  // 歌唱時間
            
            // 初期化処理
            public Vibrato()
            {
                vibrato_score = 0;
                vibrato_num = 0;
                time = 0;
            }

            // ビブラートを1回評価する
            // scale: 出した数値音階、start_index: 始まりのインデックス番号、index_size: その音符のインデックス長さ、time: 歌唱時間
            // 戻り値: 0-> 感知されなかった、1-> 感知された
            public int Evaluate(float[] scale, int start_index, int index_size, int time)
            {
                this.time = time;
                const int necessary_wave_num = 2;  // 感知に必要な波の数
                const int limit_time = 50;  // ビブラート感知の限界時間(*10ms)
                const float limit_height_min = 0.5f;  // ビブラート感知可能な限界最低縦幅(数値音階)
                vibrato_num += 1;
                int break_flag = 0;

                for (int i = 0; i < index_size; i++)
                {
                    int base_index = (start_index + i) % scale.Length;  // ビブラートの起点とするインデックス
                    float base_scale = scale[base_index];  // 起点の数値音階
                    // 発音されてない場合は無効
                    if (scale[base_index] == 0.0f) continue;

                    // 感知の段階。n:now, b:base_scale, h:limit_height_min
                    // mod 4 について。必要感知波数を変えられるように。 
                    // 0-> n >= b、 1-> n >= b+h、 2-> n <= b、 3-> n <= b-h
                    // 0-> n <= b、-1-> n <= b-h、-2-> n >= b、-3-> n <= b-h
                    int process = 0;

                    for (int j = 1; j < limit_time && i + j < index_size; j++)
                    {
                        int index = (base_index + j) % scale.Length;
                        float dif = scale[index] - base_scale;  // 起点の数値音階との差
                        int process_mod = Math.Abs(process) % 4;

                        // 発音されてない場合は無効
                        if (scale[index] == 0.0f) break;

                        // sin的な波
                        if (process >= 0)
                        {
                            if (process_mod == 0)
                            {
                                if (dif >= limit_height_min) process++;
                            }
                            else if (process_mod == 1)
                            {
                                if (dif <= 0) process++;
                            }
                            else if (process_mod == 2)
                            {
                                if (dif <= -limit_height_min) process++;
                            }
                            else
                            {
                                if (dif >= 0) process++;
                            }
                        }
                        // -sin的な波
                        else
                        {
                            if (process_mod == 0)
                            {
                                if (dif <= -limit_height_min) process--;
                            }
                            else if (process_mod == 1)
                            {
                                if (dif >= 0) process--;
                            }
                            else if (process_mod == 2)
                            {
                                if (dif >= limit_height_min) process--;
                            }
                            else
                            {
                                if (dif <= 0) process--;
                            }
                        }

                        // 波を2つ感知できていたらOK!
                        if (Math.Abs(process) == 4 * necessary_wave_num)
                        {
                            vibrato_score++;
                            break_flag = 1;
                            break;
                        }
                    }
                    if (break_flag == 1) break;
                }

                if (break_flag == 0) return 0;
                else return 1;
            }

            // ビブラートの評価値を返す
            public float GetScore()
            {
                if (time == 0)
                {
                    return 0.0f;
                }
                else
                {
                    return 100.0f * (3.0f * (float)vibrato_score / ((float)time / 100.0f));
                }
            }
        }


        class Singup
        {
            private int singup_score;  // 評価点。感知できたノート数
            private int singup_num;  // 評価したノートの数
            private int time;  // 歌唱時間
            
            // 初期化処理
            public Singup()
            {
                singup_score = 0;
                singup_num = 0;
                time = 0;
            }

            // しゃくりを1回評価する
            // scale: 出した数値音階、best_scale: 出すべき数値音階、start_index: 始まりのインデックス番号、index_size: その音符のインデックス長さ、time: 歌唱時間
            // 戻り値: 0-> 感知されなかった、1-> 感知された
            public int Evaluate(float[] scale, float best_scale, int start_index, int index_size, int time)
            {
                this.time = time;
                const int necessary_time = 5;  // しゃくり感知のために必要な時間(*10ms)
                const int limit_time = 30;  // しゃくり感知の限界時間(*10ms)
                const int necessary_stable_time = 5; // しゃくり感知のために必要な、正しい音程での安定時間(*10ms)
                singup_num += 1;

                int ok = 0;  // しゃくり感知したら1
                int process = -1;  // -1:音程が下になるまで待つ、0:音程のずり上がり中、それ以外:正しい音程での安定中(数値は正しい音程に達した時間)
                int start_time = 0;  // 低音を出し始めた時間

                for (int i = 0; i < index_size && i < limit_time; i++)
                {
                    int index = (start_index + i) % scale.Length;  // チェックするインデックス
                    if (process == -1 && scale[index] == 0.0f) break;  // 発音していない部分があれば終了
                    else if (best_scale - 0.5f <= scale[index] && scale[index] <= best_scale + 0.5f)  // 正しい音程
                    {
                        if (process == 0)
                        {
                            if (i - start_time >= necessary_time - 1)
                            {
                                process = i;
                            }
                            else if (i - start_time >= limit_time)
                            {
                                break;
                            }
                        }
                        else if (process > 0)
                        {
                            if (i - process >= necessary_stable_time)
                            {
                                singup_score++;
                                ok = 1;
                                break;
                            }
                        }    
                    }
                    else if (process > 0) break;  // 一旦正しい音程に達したのに、安定して正しい音程が出せなかった
                    else if (scale[index] < best_scale - 0.5f)  // 正しい音程より低い音を出している
                    {
                        process = 0;
                        start_time = i;
                    }
                    else break;
                }

                return ok;
            }

            // しゃくりの評価値を返す
            public float GetScore()
            {
                if (time == 0)
                {
                    return 0.0f;
                }
                else
                {
                    return 100.0f * (3.0f * (float)singup_score / ((float)time / 100.0f));
                }
            }
        }


        private Interval interval;
        private Timing timing;
        private Smoothness smoothness;
        private Vibrato vibrato;
        private Singup singup;


        // 最初に実行する
        public Evaluator(int all_time)
        {
            this.all_time = all_time;
            time = 0;
            interval = new Interval();
            timing = new Timing();
            smoothness = new Smoothness();
            vibrato = new Vibrato();
            singup = new Singup();
        }

        // 音程評価のために実行する。瞬間瞬間で回す
        // 歌唱時間の計算も行う
        // scale: 出した数値音階、best_scale: 出すべき数値音階
        public void EvaluateInterval(float scale, float best_scale)
        {
            interval.Evaluate(scale, best_scale);
            time += 1;
        }

        // タイミング評価のために実行する。1つのノートが終わったら実行する
        // scale: 出した数値音階の配列、best_scale: 出すべき数値音階、start_index: 出した数値音階の配列のスタートインデックス、index_size: 評価すべきインデックス数
        public void EvaluateTiming(float[] scale, float best_scale, int start_index, int index_size)
        {
            timing.Evaluate(scale, best_scale, start_index, index_size);
        }

        // なめらかさ評価のために実行する。2つのノートが終わったら実行する
        // scale: 出した数値音階の配列、best_scale: 出すべき数値音階(2つ)、start_index: 出した数値音階の配列のスタートインデックス、index_size: 評価すべきインデックス数、border_index: 境界インデックス
        public void EvaluateSmoothness(float[] scale, float[] best_scale, int start_index, int index_size, int border_index)
        {
            smoothness.Evaluate(scale, best_scale, start_index, index_size, border_index);
        }

        // ビブラート評価のために実行する。1つのノートが終わったら実行する
        // scale: 出した数値音階の配列、start_index: 出した数値音階の配列のスタートインデックス、index_size: 評価すべきインデックス数
        // 戻り値: 0-> 感知されなかった、1-> 感知された
        public int EvaluateVibrato(float[] scale, int start_index, int index_size)
        {
            return vibrato.Evaluate(scale, start_index, index_size, time);
        }

        // しゃくり評価のために実行する。1つのノートが終わったら実行する
        // scale: 出した数値音階の配列、best_scale: 出すべき数値音階、start_index: 出した数値音階の配列のスタートインデックス、index_size: 評価すべきインデックス数
        // 戻り値: 0-> 感知されなかった、1-> 感知された
        public int EvaluateSingup(float[] scale, float best_scale, int start_index, int index_size)
        {
            return singup.Evaluate(scale, best_scale, start_index, index_size, time);
        }

        // 評価値を得る
        // id -> 0: 総合, 1: 音程, 2: タイミング, 3: なめらかさ, 4: ビブラート, 5: しゃくり
        public float GetScore(int id)
        {
            if (id == 0)
            {
                float interval_score = interval.GetScore();
                float timing_score = 2.0f * timing.GetScore() / 100.0f;
                float smoothness_score = smoothness.GetScore();
                float vibrato_score = vibrato.GetScore();
                float singup_score = singup.GetScore();

                if (interval_score >= 95.0f) interval_score = 88.0f + 2.0f * (interval_score - 95.0f) / 5.0f;
                else if (interval_score >= 90.0f) interval_score = 87.0f + 1.0f * (interval_score - 90.0f) / 5.0f;
                else if (interval_score >= 85.0f) interval_score = 85.0f + 2.0f * (interval_score - 85.0f) / 5.0f;
                else if (interval_score >= 80.0f) interval_score = 83.0f + 2.0f * (interval_score - 80.0f) / 5.0f;
                else interval_score = 0.0f + 83.0f * (interval_score - 0.0f) / 80.0f;

                if (smoothness_score > 100.0f) smoothness_score = 1.0f;
                else smoothness_score = 1.0f * smoothness_score / 100.0f;

                if (vibrato_score > 100.0f) vibrato_score = 7.0f;
                else vibrato_score = 7.0f * vibrato_score / 100.0f;

                if (singup_score > 100.0f) singup_score = 3.0f;
                else singup_score = 3.0f * singup_score / 100.0f;

                return (interval_score + timing_score + smoothness_score + vibrato_score + singup_score);
            }
            else if (id == 1) return interval.GetScore();
            else if (id == 2) return timing.GetScore();
            else if (id == 3) return smoothness.GetScore();
            else if (id == 4) return vibrato.GetScore();
            else return singup.GetScore();
        }

        // デバッグ用
        public void Debug()
        {
            UnityEngine.Debug.Log("all_time->" + all_time + ", time->" + time);
        }
    }
}

感想

本来の黒よりテクニック評価が厳しいので、なかなかつらい採点になっている。
GUI.DrawTextureで反応テクニックを表示させるのが一番苦労した。
もちろん、こんな採点機はゴミも同然なので、今からオリジナルの採点機を作る。間に合うのかな

5.6 要素の調整と融合

  1. Unityを用いる
    1. C#について少し復習する
    2. Unityの2D描画について簡単に学ぶ
  2. Unityの音声制御
    1. MIDIを再生させる手段を見つける
    2. 音声のマイク入力手段を見つける
  3. 入力音声の解析
    1. UnityのFFTライブラリを触ってみる
    2. FFTされたデータから音程を確定させるアルゴリズムを考える
  4. MIDIの解析
    1. 第1トラックの楽譜を解析する
    2. MIDIの再生箇所を解析する手段を見つける
  5. 採点のための解析
    1. 全体の点数計算アルゴリズムを考える
    2. ビブラート判定アルゴリズムを考える
    3. しゃくり判定アルゴリズムを考える
    4. タイミング判定アルゴリズムを考える
    5. なめらかさ判定アルゴリズムを考える

f:id:ibako31:20140214002026j:plain

特に新しいことはやっていない。今まで作ってきた要素を調整し、融合して1つのものとした。

譜面の調整

バックに音程の基準を表す土台をつけた。左端に音程も示している。黒鍵音は黒っぽいバックにしていたりと何気に凝っている。

譜面はGUITextureを使って表示していたが、そもそもGUI系は画面上に表示するゲージとかに使われるもので、最も上に描画されてしまう。今回の場合、それは非常に困るので、新しいGameObject(Plane)を生成してテクスチャを貼り付ける作戦に出た。
譜面をめくる度に、以下の処理を行う。

  1. ノートを全て消去する(GameObjectをDestroyする)
  2. 現在の譜面で必要なノートを計算する
  3. 新たなGameObjectを生成する

しかし、この方法だと譜面をめくる度に0.3秒ほどの処理落ちが発生した。どうやらGameObjectの生成・削除は少し重い処理らしい。

よって、以下の方法に変更した。

  1. 初期化時にノートを全て計算し、GameObjectを生成する
  2. 譜面をめくる度に、全てのノートを非表示にする(SetActive(false))。また、現在の譜面で必要なノートを計算し表示を有効にする

これでもやや処理落ちが発生するものの、全然マシだったので採用。

あと苦労したのは、ノートの表示位置の基準。以下のアルゴリズムで最高点の音程を決定している。

  1. 表示している譜面の平均音程を中央に持ってくるように最高点の音程を決定する
  2. 表示している譜面の最高音程が表示できていない場合、最高点の音程をその音程とする
  3. 2以外の場合で表示している譜面の最低音程が表示できていない場合、最低点の音程をその音程とする

…だけど、どうやらどこかにバグが有るらしく、たまに上手く動かない(妙に上に表示される)。まぁ、ぼちぼちと修正していこう。

演奏位置の表示

赤線のこと。
Unityには再生中のオーディオの再生位置を手に入れる機能が標準で搭載されている。

public AudioSource obj;

void Start(){
    obj.Play();
}

void Update(){
    Debug.Log(obj.time);  // 再生位置がfloat型・秒数で取得できる
}

なお、数値を弄って再びPlayすることで指定位置から再生も可能。
これと全ての要素を同期させている。

歌唱線の表示

音が取れていない場所でも線が繋がるのは不格好なので、いくつかLineRendererを用意して…と考えていたが、非常に面倒なのでパスした。まぁ、機能上そこまで影響ないやろ…。
ノートがない場所では譜面中に収まるオクターブに、ある場所ではノートとの差が最も小さくなるオクターブに合わせて表示する。JOYでよく発生するオクターブエラーはこれで回避できる。

16:9表示への対応

カメラサイズを適当にしていたので、全画面表示で左右が切れてしまった。
今の時代だと16:9のモニターが標準だと思うので、それに合わせて作りなおした。


f:id:ibako31:20140214002009j:plain

カエルの歌を歌った。

5.5 MIDIの解析

  1. Unityを用いる
    1. C#について少し復習する
    2. Unityの2D描画について簡単に学ぶ
  2. Unityの音声制御
    1. MIDIを再生させる手段を見つける
    2. 音声のマイク入力手段を見つける
  3. 入力音声の解析
    1. UnityのFFTライブラリを触ってみる
    2. FFTされたデータから音程を確定させるアルゴリズムを考える
  4. MIDIの解析
    1. 第1トラックの楽譜を解析する
    2. MIDIの再生箇所を解析する手段を見つける
  5. 採点のための解析
    1. 全体の点数計算アルゴリズムを考える
    2. ビブラート判定アルゴリズムを考える
    3. しゃくり判定アルゴリズムを考える
    4. タイミング判定アルゴリズムを考える
    5. なめらかさ判定アルゴリズムを考える

やりたいことをやってくれるライブラリが見つからなかったので、今回はとても苦労しました。

Standard MIDI Format

MIDIは、音楽ファイルの形式の一つである。
.wavや.mp3は音波データそのものを記録しているため、再現性が高い代わりにデータが大きく編集が困難である。対照的に、.midは楽譜データを記録しているため、再現性が低い(音源依存)代わりにデータが小さく編集が容易である。
そんなMIDIの標準的な規格が、Standart MIDI Format(SMF)である。
SMFは、ヘッダ部分とトラックデータ部分に分かれている。

たとえば、以下のようなMIDIファイルを考える。これはDominoという有名なエディタである。
f:id:ibako31:20140209004213j:plain
見えていないが、第1トラックでテンポを120に設定している。また、見えている第2トラックでは四分音符でド、四分音符でレ、二分音符でミを鳴らし、一小節で終わる。
これをフォーマット1(後述)でSMF(Standard MIDI File)に変換し、バイナリエディタで開いてみる。
f:id:ibako31:20140209004406j:plain
今回は、このデータを解析していく。このデータは、ヘッダ+トラックごとのデータで構成されている。

ヘッダ

f:id:ibako31:20140209004617j:plain

4D 54 68 64 00 00 00 06 ** ** ** ** ** **
"MThd" 実データの大きさ(必ず6) フォーマット(0000/0001/0002) トラック数 時間単位

ヘッダは以上の14bytesからなる。

フォーマット

フォーマット0は、全てのトラック(パート)を1トラックに押し込めたデータである。プレイヤーが譜面を起こすのが楽になる反面、編集データからの不可逆的な変換のため編集が困難になる。
フォーマット1は、トラックごとに譜面データを記録している。こちらは編集データから可逆的な変換である。
フォーマット2は、まず使われることがないらしいので省略。

トラック数

パートの数。

時間単位

正の数(先頭ビットが0)の場合、デルタタイム(後述)において、四分音符1個分を表す数値。分解能のことだと思えば良い。
負の数(先頭ビットが1)の場合は違う意味を表すが、まぁ、省略。

これを基に、画像のデータを解析してみよう。

  • フォーマット … 00 01 -> 1
  • トラック数 … 00 02 -> 2
  • 時間単位 … 01 E0 -> 480

実データ

ヘッダの後に、トラックごとのデータが続く。たとえば、トラック2は以下の部分である*1
f:id:ibako31:20140209005651j:plain
それぞれのトラックについて、以下の様な構造になっている。

4D 54 72 6B ** ** ** ** ** ** ~
"MTrk" 実データのデータ長 実データが続く

すなわち、"MTrk"で始まった後4bytesで表されたデータ長があり、実データが続く。

実データは以下の様な構造になっている。ここがややこしい部分である。

** ~ ** **~
デルタタイム ステータスバイト 諸々
デルタタイム

以降続く命令を実行するまでに待機する時間。RoboLabを触ったことがある人は、アレのウェイトと同じようなものだと考えれば良い。ヘッダで設定した時間単位を基準として記述する。
ところで、デルタタイムは何byteあれば十分なのだろうか。SMFでは、デルタタイムは4bytesまで設定可能となっている。ところが、大抵の命令のデルタタイムはもっと少ないbyteで収まることは容易に想像できると思う。
そこで、SMFでは可変長形式でデルタタイムが記述されている。これがめんどくさい。

可変長形式

理屈は単純で、先頭bitが1の時は続くbyteもデルタタイムを表し、0の時はそこがデルタタイム表現の終点であるだけである。これを実際のbit列に変換するには、先頭ビットを取っ払って右に詰めれば良い。
たとえば、以下の様になっている。接続判断bitを赤色で示す。また、見やすいように2進数表記は1byteずつ縦線で区切った。

16進数 2進数 可変長2進数 可変長16進数
40 0100 0000 0100 0000 40
7F 0111 1111 0111 1111 7F
80 1000 0000 1000 0001 | 0000 0000 8100
200000 0010 0000 | 0000 0000 | 0000 0000 1000 0001 | 1000 0000 | 1000 0000 | 0000 0000 81 80 80 00

要点は、今見ているアドレスをindexとすると先頭ビットが1かどうか判断するには以下のようなコードを書けば良いということである。

if((index & (byte)0x80) > 0){
    // byte型のindexは先頭ビットが1
}
ステータスバイト

1byteで、命令の種類を表す。以下に、カラオケ採点上必要な項目のみ述べる。nは0~Fまでの数値で、対象チャンネルを表す。また、ノートオン/オフの場合、ステータスバイトの後の1byteは対象音階を表し、その後の1byteはベロシティ(強さ)を表す。

** 意味
8n ノートオフ
9n ノートオン
(A~E)n ほげほげ
F0またはF7 ふがふが*2
FF メタイベント(後述)
ランニングステータス

上で述べた通り、ステータスバイトは「(8~F)* **」の形式であり、すなわち先頭ビットは必ず1である。ここで、ステータスバイトの先頭ビットが1でない場合、前回のステータスバイトを受け継ぐ(すなわち、ステータスバイトを省略する)というデータ圧縮が可能である。これをランニングステータスと呼ぶ*3

メタイベント

色々とオプション的なイベントである。カラオケ採点においてはテンポ変更とトラック終端くらいしか重要なものがないので、そこを中心に述べる。
メタイベントは以下の様な形式である。

ステータスバイト メタイベント種類 データ長(可変長形式) データ 意味
FF 51 03 ** ** ** テンポ変更
FF 2F 00 ---- トラック終端

メタイベントは必ず上のような形式になっているため、カラオケ採点の場合は読み込み時、テンポ変更以外のメタイベントに当たった場合、データ長を把握してその分スキップする処理が重要である。

テンポ変更について。
データは3bytesである。この数値は四分音符のマイクロ秒を表す。つまり、BPM(1分あたりの四分音符の数)をBとすると、60 \cdot 10^6 / Bとなる。


以上を把握した上で、先ほどのトラック2の実データ部分(54byte目から)を読んでみよう。
f:id:ibako31:20140209005651j:plain
分かりやすくするため、デルタタイムは赤色、ステータスバイトは黄色、メタイベントは水色で表す。

イベント 意味
00 FF 03 00 デルタタイム0後にメタイベント03を実行、データ長は0(つまり、無い)
00 FF 21 01 00 デルタタイム0後にメタイベント21を実行、データ長は1でデータは0
00 90 30 64 デルタタイム0後にチャンネル0の音階30をベロシティ64でON
83 60 80 30 00 デルタタイム83 60(可変長形式)後にチャンネル0の音階30をOFF
00 90 32 64 デルタタイム0後にチャンネル0の音階32をベロシティ64でON
83 60 80 32 00 デルタタイム83 60(可変長形式)後にチャンネル0の音階32をOFF
00 90 34 64 デルタタイム0後にチャンネル0の音階34をベロシティ64でON
87 40 80 34 00 デルタタイム87 40(可変長形式)後にチャンネル0の音階34をOFF
00 FF 2F 00 トラック終端

つまり、音階30はド、音階32はレ、音階34はミということである。

フォーマット1のデータ読み込み

フォーマット1の場合、トラックごとに変数を作り、並列的に読んでいくしか無い。
現在の時間から最も短い時間でイベントにたどり着けるのがどのトラックか把握し、それを再帰的に処理すれば良い。

カラオケ採点のために

カラオケ採点のためには、「どのようなイベントが」「どのくらいの時間(秒)」継続するか知ることが大切である。
そこで、イベントを「何もしない」「ノートhogeをON」「テンポ変更」「トラック終端」の4種類に分けてそれに継続時間を付属させた。
以下、SMFLibraryのソースである。投げやりな感じで貼り付けるが、上の説明をよく読めば理解できるはずだし、私も相当苦労したのでこれで勘弁して欲しい。ソース中にはコメントを大量に付けたので、それを読めばある程度理解できるはずである。

using UnityEngine;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;

namespace SMFLibrary
{
    // Standard Midi Format のカラオケ用読み込みに特化したライブラリ
    public class StandardMidiFormat
    {
        // イベントリスト用
        public class Event
        {
            // 0:何もしない 1:BPM変更 2:ノートON -1:EOF
            public int type;

            // イベント内容。タイプに応じて意味が違う
            // 1:BPM 2:ONにするノート 0,-1:未定義
            // ノートはA0(55Hz)が0になり、半音上がるごとに1上がる
            public int value;

            // イベントの継続時間(ms)
            public int time;
        }

        
        // 現在読み込んでいるファイルの名前
        public string filename { get; private set; }

        // 現在読み込んでいるMIDIのイベントリスト
        // ただし、BPM変更とノートON/OFF(1音)のみ
        public List<Event> event_list;

        // 4分音符に当たるデルタタイム
        private int time_unit;

        // テンポ(四分音符に当たるms)
        private int tempo;


        // byte列からshort型整数に変換する
        private int ToInt16(byte[] data, int index)
        {
            byte[] ar = new byte[2];
            ar[0] = data[index];
            ar[1] = data[index + 1];
            if (BitConverter.IsLittleEndian)
            {
                Array.Reverse(ar);
            }
            return BitConverter.ToInt16(ar, 0);
        }

        
        // byte列からint型整数に変換する
        private int ToInt32(byte[] data, int index)
        {
            byte[] ar = new byte[4];
            ar[0] = data[index];
            ar[1] = data[index + 1];
            ar[2] = data[index + 2];
            ar[3] = data[index + 3];
            if (BitConverter.IsLittleEndian)
            {
                Array.Reverse(ar);
            }
            return BitConverter.ToInt32(ar, 0);
        }


        // bit列からint型整数に変換する
        private int ToInt32FromBits(byte[] data, int index)
        {
            int value = 0;
            for (int i = 0; i < 32; i++)
            {
                value += data[index + 31 - i] * (int)Math.Pow(2, i);
            }
            return value;
        }


        // バイナリファイルを読み込み、配列で返す
        private byte[] _load_binary(string filename)
        {
            System.IO.FileStream fs = new System.IO.FileStream(@filename, System.IO.FileMode.Open, System.IO.FileAccess.Read);
            int filesize = (int)fs.Length;
            byte[] data = new byte[filesize];
            fs.Read(data, 0, filesize);
            fs.Dispose();

            return data;
        }


        // dataのindexから始まる可変長形式の数値をint型に変換する
        // plus_posは、可変長形式の数値がそこから何byteあったか
        private int _convert_variable_to_int(byte[] data, int index, out int plus_pos)
        {
            // 可変長形式の最大byte
            int max_variable_size = 5;

            // 変換後の最大byte
            int max_int_size = 4;
            
            // データサイズ
            int size = 1;
            for(int i = 0; (data[index+i] & (byte)0x80) > 0; i++)
            {
                size++;
            }
            if (size > max_variable_size)
            {
                Debug.LogError("variable size is bigger than 5");
                plus_pos = 0;
                return 0;
            }
            
            // 変換後の各ビットデータ
            byte[] bits = new byte[max_int_size*8];
            
            // 埋めるべきビット
            int pos = bits.Length - 1;
            
            // ビットデータを埋める
            for (int i = 0; i < size; i++)
            {
                for(int j = 0; j < 7; j++)
                {
                    byte comp = (byte)(1 << j);
                    if((data[index+size-1-i] & comp) > 0)  bits[pos] = 1;
                    pos--;
                }
            }

            // 埋まったビットデータからintに変換して返す
            plus_pos = size;
            return ToInt32FromBits(bits, 0);
        }


        // デルタタイムを実際の時間(ms)に変換する
        private int _convert_delta_time_to_ms(int delta_time)
        {
            return (int)((float)tempo * (float)delta_time / (float)time_unit);
        }

        // data内の第nトラックがindex[n-1]から始まるとして、最も早く実行される命令を返す
        // before_commandはトラックの直前ステータスバイト、timeはトラックの現在時間
        // indexとtimeの中身は変更される。indexとtimeが-1になったら、そのトラックは終了した
        // どのトラックも終了していたらnullを返す
        private Event _get_command(byte[] data, int[] index, byte[] before_command, int[] time)
        {
            // 現在の全体の時間
            int world_time = time.Max();

            // 最小の(world_timeからの)デルタタイム・最小デルタタイムのインデックスナンバー・動かすインデックス座標
            int min_delta_time = -1;
            int min_index = -1;
            int min_plus_pos = -1;
            for (int i = 0; i < index.Length; i++)
            {
                if (index[i] >= 0)
                {
                    int plus_pos;
                    int delta_time = time[i] + _convert_variable_to_int(data, index[i], out plus_pos) - world_time;
                    if (min_delta_time < 0 || delta_time < min_delta_time)
                    {
                        min_delta_time = delta_time;
                        min_index = i;
                        min_plus_pos = plus_pos;
                    }
                }
            }

            // 次に実行されるトラックが分かったから、その内容を読んでindexと時間を動かす
            if (min_index >= 0)
            {
                index[min_index] += min_plus_pos;
                time[min_index] += min_delta_time;

                // ステータスバイト
                byte status_byte = data[index[min_index]];
                Event ev = new Event();

                // ステータスバイトの最上位ビットが1なら、具体的命令
                if ((status_byte & (byte)0x80) > 0)
                {
                    index[min_index]++;
                    before_command[min_index] = status_byte;
                }
                // ステータスバイトの最上位ビットが0なら、前回の命令
                else
                {
                    status_byte = before_command[min_index];
                }
                
                byte st = (byte)(status_byte & (byte)0xF0);
                if (st == (byte)0x80) ev.type = 0;  // ノートオフ
                else if (st == (byte)0x90) ev.type = 2;  // ノートオン
                else if (status_byte == (byte)0xFF && data[index[min_index]] == (byte)0x51) ev.type = 1;  // テンポ変更
                else if (status_byte == (byte)0xFF && data[index[min_index]] == (byte)0x2F) ev.type = -1;  // トラック終端
                else  // 無効命令
                {
                    if (st == (byte)0xA0 || st == (byte)0xE0)
                    {
                        index[min_index] += 2;
                    }
                    else if (st == (byte)0xC0 || st == (byte)0xD0)
                    {
                        index[min_index] += 1;
                    }
                    else if (st == (byte)0xB0)
                    {
                        if (data[index[min_index]] == (byte)0x7E && data[index[min_index] + 2] == (byte)0x04)
                        {
                            index[min_index] += 3;
                        }
                        else
                        {
                            index[min_index] += 2;
                        }
                    }
                    else
                    {
                        int plus_pos;
                        int length = _convert_variable_to_int(data, index[min_index] + 1, out plus_pos);
                        index[min_index] += 1 + plus_pos + length;
                    }
                    return _get_command(data, index, before_command, time);
                }

                // ノートオフかオンかテンポ変更の命令だけ抽出したので、諸々の処理
                // 次のデルタタイムが記録されているインデックスナンバー
                int next_delta_time_index = index[min_index];  
                
                // 何もしない処理
                if (ev.type == 0)
                {
                    next_delta_time_index += 2;
                    index[min_index] += 2;
                }
                // BPM変更の処理
                else if (ev.type == 1)
                {
                    next_delta_time_index += 5;
                    ev.value = 0;
                    ev.value += (int)((uint)data[index[min_index] + 2] * 65536);
                    ev.value += (int)(uint)(data[index[min_index] + 3] * 256);
                    ev.value += (int)(uint)(data[index[min_index] + 3]);
                    ev.value /= 1000;
                    tempo = ev.value;
                    index[min_index] += 5;
                }
                // ノートオンの処理
                else if (ev.type == 2)
                {
                    next_delta_time_index += 2;
                    ev.value = (int)data[index[min_index]];
                    index[min_index] += 2;
                }
                // トラック終端の処理
                else if (ev.type == -1)
                {
                    index[min_index] = -1;
                    time[min_index] = -1;
                }
                int pp;
                // デルタタイムを実際の時間(ms)に変換して代入
                ev.time = _convert_delta_time_to_ms( _convert_variable_to_int(data, next_delta_time_index, out pp) );

                return ev;
            }
            return null;
        }


        // ファイルを読み込み、イベントリストを得る
        public void Load(string filename)
        {
            byte[] data = _load_binary(filename);
            event_list = new List<Event>();
            tempo = 500;  // BPM120

            // ヘッダを読み込む
            if (data.Length < 4)
            {
                Debug.LogError("File Length is not suitable.");
            }
            else if (!(data[0] == (byte)'M' && data[1] == (byte)'T' && data[2] == (byte)'h' && data[3] == (byte)'d'))
            {
                Debug.LogError("File is not Standard MIDI Format.");
            }
            else if (data[9] == 2)
            {
                Debug.LogError("Format 2 is not suitable.");
            }
            else
            {
                // このMIDIのフォーマット
                int format = ToInt16(data, 8);

                // このMIDIのトラック数
                int track_num = ToInt16(data, 10);

                // 4分音符に当たるデルタタイム
                time_unit = ToInt16(data, 12);

                if (format == 2)
                {
                    Debug.LogError("format is 2.");
                }
                else if (time_unit < 0)
                {
                    Debug.LogError("time_unit is minus.");
                }
                else
                {
                    // 読み込んでいるインデックス(トラックごと)
                    int[] pos = new int[track_num];

                    pos[0] = 22;
                    for (int i = 1; i < track_num; i++)
                    {
                        pos[i] = pos[i - 1] + ToInt32(data, pos[i - 1] - 4) + 8;
                    }

                    // 各トラックの現在の時間
                    int[] time = new int[track_num];

                    // 各トラックの直前命令
                    byte[] before_command = new byte[track_num];

                    // 「何もしない」をイベントリストの先頭に追加
                    int min_time = -1;
                    for (int i = 0; i < track_num; i++)
                    {
                        int plus_pos;
                        int t = _convert_variable_to_int(data, pos[i], out plus_pos);
                        if (min_time < 0 || t < min_time) min_time = t;
                    }
                    Event first_ev = new Event();
                    first_ev.type = 0;
                    first_ev.time = min_time;
                    event_list.Add(first_ev);

                    // 各トラックのうち、現在の時間+デルタタイムが最も小さいものはどれか?
                    // その命令を解析する
                    while (pos[0] != -1 || (pos.Length >= 2 && pos[1] != -1))
                    {
                        Event ev = _get_command(data, pos, before_command, time);
                        event_list.Add(ev);
                    }

                    // メンバの変更
                    this.filename = filename;

                }            
            }
        }
    }
}

これを使って、以下のようなMIDIファイルを解析し、Unity上で表示した。Un-Demystifed Fantasy の主旋律である。
f:id:ibako31:20140209015339j:plain
f:id:ibako31:20140209015448j:plain
なんか左に詰めて表示されるなぁ…。

フォーマット0にして、もう一度やってみた。
f:id:ibako31:20140209015635j:plain

今度は上手く行った。フォーマット0推奨にしたほうが良いかもしれない。まぁ、必要なのは主旋律データだけだし、フォーマット0でも問題ないか…。


バイナリデータ弄ったり、信号変換したりの作業は楽しかったが、実に非人間的であった。二度とやりたくない。

*1:トラック1は短いが、様々な特殊命令が複合されていてややこしいのでトラック2を例に挙げた

*2:本当はデータ長的に無視できないが、そのような命令を入れないということを前提にしたい(不精してるだけ)

*3:Dominoで出力したSMFにはランニングステータスは適用されていないようである

5.4 マイク入力の音程解析

  1. Unityを用いる
    1. C#について少し復習する
    2. Unityの2D描画について簡単に学ぶ
  2. Unityの音声制御
    1. MIDIを再生させる手段を見つける
    2. 音声のマイク入力手段を見つける
  3. 入力音声の解析
    1. UnityのFFTライブラリを触ってみる
    2. FFTされたデータから音程を確定させるアルゴリズムを考える
  4. MIDIの解析
    1. 第1トラックの楽譜を解析する
    2. MIDIの再生箇所を解析する手段を見つける
  5. 採点のための解析
    1. 全体の点数計算アルゴリズムを考える
    2. ビブラート判定アルゴリズムを考える
    3. しゃくり判定アルゴリズムを考える
    4. タイミング判定アルゴリズムを考える
    5. なめらかさ判定アルゴリズムを考える

注文したマイクが届いた。
f:id:ibako31:20140206223250j:plain
ちなみに、コレである。ケーズデンキの通販で買って2400円位だった。安い。
正しい用途は動画投稿サイトへの歌の投稿らしい。

SONY エレクトレットコンデンサーマイクロホン PCV80U ECM-PCV80U

SONY エレクトレットコンデンサーマイクロホン PCV80U ECM-PCV80U

サウンドライブラリ

マイク入力については少し置いておき、先日までに書いた音程解析メソッドを整理してライブラリとする。

using UnityEngine;
using System.Collections;

public static class SoundLibrary{

    // オーディオの周波数を調べる
    // ac: 解析したいオーディオソース
    // qSamples: 解析結果のサイズ
    // threshold: ピッチの閾値
    public static float AnalyzeSound(AudioSource ac, int qSamples, float threshold)
    {
        float[] spectrum = new float[qSamples];
        ac.GetSpectrumData(spectrum, 0, FFTWindow.BlackmanHarris);
        float maxV = 0;
        int maxN = 0;
        //最大値(ピッチ)を見つける。ただし、閾値は超えている必要がある
        for (int i = 0; i < qSamples; i++)
        {
            if (spectrum[i] > maxV && spectrum[i] > threshold)
            {
                maxV = spectrum[i];
                maxN = i;
            }
        }

        float freqN = maxN;
        if (maxN > 0 && maxN < qSamples - 1)
        {
            //隣のスペクトルも考慮する
            float dL = spectrum[maxN - 1] / spectrum[maxN];
            float dR = spectrum[maxN + 1] / spectrum[maxN];
            freqN += 0.5f * (dR * dR - dL * dL);
        }

        float pitchValue = freqN * (AudioSettings.outputSampleRate / 2) / qSamples;
        return pitchValue;
    }

    // ヘルツから音階への変換
    public static float ConvertHertzToScale(float hertz)
    {
        float value = 0.0f;
        if (hertz == 0.0f) return value;
        else
        {
            value = 12.0f * Mathf.Log(hertz / 110.0f) / Mathf.Log(2.0f);
            // while (value <= 12.0f) value += 12.0f;
            // while (value > 36.0f) value -= 12.0f;
            return value;
        }
    }

    // 数値音階から文字音階への変換
    public static string ConvertScaleToString(float scale)
    {
        // 12音階の何倍の精度で音階を見るか
        int precision = 2;

        // 今の場合だと、mod24が0ならA、1ならAとA#の間、2ならA#…
        int s = (int)scale;
        if (scale - s >= 0.5) s += 1; // 四捨五入
        s *= precision;

        int smod = s % (12 * precision); // 音階
        int soct = s / (12 * precision); // オクターブ

        string value; // 返す値

        if (smod == 0) value = "A";
        else if (smod == 1) value = "A+";
        else if (smod == 2) value = "A#";
        else if (smod == 3) value = "A#+";
        else if (smod == 4) value = "B";
        else if (smod == 5) value = "B+";
        else if (smod == 6) value = "C";
        else if (smod == 7) value = "C+";
        else if (smod == 8) value = "C#";
        else if (smod == 9) value = "C#+";
        else if (smod == 10) value = "D";
        else if (smod == 11) value = "D+";
        else if (smod == 12) value = "D#";
        else if (smod == 13) value = "D#+";
        else if (smod == 14) value = "E";
        else if (smod == 15) value = "E+";
        else if (smod == 16) value = "F";
        else if (smod == 17) value = "F+";
        else if (smod == 18) value = "F#";
        else if (smod == 19) value = "F#+";
        else if (smod == 20) value = "G";
        else if (smod == 21) value = "G+";
        else if (smod == 22) value = "G#";
        else value = "G#+";
        value += soct + 1;

        return value;
    }

    // 数値音階から生波形を出す
    public static void ScaleWave(float[] scale, int size, LineRenderer line)
    {
        line.SetVertexCount(size);
        float x = -150.0f;
        for (int i = 0; i < size; i++)
        {
            line.SetPosition(i, new Vector3(x, -80.0f + scale[i] * 2.5f, 0));
            x += 1.0f;
        }
    }
}

マイク入力の音程解析

簡単に言うと、以下の様な仕組みである。

  1. AudioSourceのclip(流すファイル)とマイク入力を関連付ける
  2. そのオーディオを再生させる(録音音声を聞きたくない場合はミュートにする)
  3. 前回の記事の要領で音程解析を行う

重要なのは以下のメソッド。

AudioClip Microphone.Start(string deviceName, bool loop, int lengthSec, int frecency)

deviceName

録音に使うマイクの名前。

loop

録音時間が尽きたら最初から録音を再開するか

lengthSec

録音時間

frecency

サンプリング周波数

以下が音程解析ソース。以前までの記事に詳しいことは書いているので、特に説明は必要ないと思う。

using UnityEngine;
using System.Collections;

public class MicroFFT : MonoBehaviour {
    // 波形を描画する
    public LineRenderer line;

    // マイクからの音を拾う
    public AudioSource mic;
    private string mic_name = "UAB-80";

    // 波形描画のための変数
    private float[] wave;
    private int wave_num;
    private int wave_count;


    void Start () {
        // 波形描画のための変数の初期化
        wave_num = 300;
        wave = new float[wave_num];
        wave_count = 0;
        
        // micにマイクを割り当てる
        mic.clip = Microphone.Start(mic_name, true, 999, 44100);
        if (mic.clip == null)
        {
            Debug.LogError("Microphone.Start");
        }
        mic.loop = true;
        mic.mute = true;

        // 録音の準備が出来るまで待つ
        while (!(Microphone.GetPosition(mic_name) > 0)) { }
        mic.Play();
    }
	
    void Update () {
        // 諸々の解析
        float hertz = SoundLibrary.AnalyzeSound(mic, 1024, 0.04f);
        float scale = SoundLibrary.ConvertHertzToScale(hertz);
        string s = SoundLibrary.ConvertScaleToString(scale);
        Debug.Log(hertz + "Hz, Scale:" + scale + ", " + s);
        
        // 波形描画
        wave[wave_count] = scale;
        SoundLibrary.ScaleWave(wave, wave_count, line);
        wave_count++;
        if (wave_count >= wave_num) wave_count = 0;
    }
}

これで、適当にマイクに向かって発声してみました。
f:id:ibako31:20140206224458j:plain
なかなか綺麗に出ます。さすが単一指向マイクです。
ちなみに、私の声域(マイクが拾ってくれた音)は約98Hz~630Hzでした。およそ2オクターブ半なので、まぁ妥当なところだと思います。
AudioSourceをもう一つ追加し、BGMを流しながらちょっとだけ歌いました。
f:id:ibako31:20140206225203j:plain
CROSSOっぽくなってきました。

課題

  • 最初のテストでは2秒の遅延が生じた(以降はほぼ遅延なし)。どうやら、録音準備が出来て実際に録音がスタートしてから、mic.Play()が有効化するのに時間がかかると遅延が発生するらしい。これは回避手段があるのだろうか…。あるとすれば、録音中の時間と再生時間を合わせるくらいか(出来るのか)。
  • 「あ」から「ん」まで発音したが、さ行とた行辺りでノイズが強く発生した。また、「る」でも大きく波がブレた。もしかして、「る」の特殊歌唱ってあまり良くなかったのでは…

5.3 FFTとオーディオの音程解析

  1. Unityを用いる
    1. C#について少し復習する
    2. Unityの2D描画について簡単に学ぶ
  2. Unityの音声制御
    1. MIDIを再生させる手段を見つける
    2. 音声のマイク入力手段を見つける
  3. 入力音声の解析
    1. UnityのFFTライブラリを触ってみる
    2. FFTされたデータから音程を確定させるアルゴリズムを考える
  4. MIDIの解析
    1. 第1トラックの楽譜を解析する
    2. MIDIの再生箇所を解析する手段を見つける
  5. 採点のための解析
    1. 全体の点数計算アルゴリズムを考える
    2. ビブラート判定アルゴリズムを考える
    3. しゃくり判定アルゴリズムを考える
    4. タイミング判定アルゴリズムを考える
    5. なめらかさ判定アルゴリズムを考える

マイクがまだ届いていないので、とりあえずオーディオファイルの音程を解析する部分を作った。

音程解析とは

オーディオファイルの音程を解析するには、以下の様な手順を踏む。

  1. ある部分の波形をFFT高速フーリエ変換)にかけ、周波数の分布を得る
  2. ピッチ(最も強く出ている音)を確定させる
  3. 周辺の分布も加味しつつ、ピッチの音程を確定させる

ノイズの影響を受けやすく、めっちゃ難しい。

FFT高速フーリエ変換

FFT(Fast Fourier Transform)とは、離散フーリエ変換の高速アルゴリズムである。具体的にはO(n^2)がO(nlogn)になる。これがどれほど凄いことかというと、たとえばn=1000のときを考えて見れば良い。前者は100万になり、後者は約1万になる*1。計算時間が100分の1になっている。リアルタイムに離散フーリエ変換を行う際、FFTはほぼ必須となってくる。
フーリエ変換とは、まぁ簡単に言うとx軸が時間・y軸が音圧だったものをx軸が周波数・y軸が分布に変えることである。つまり、オーディオファイルのある時間帯にどのくらいの周波数の音がどのくらい存在しているかが分かる。
詳しくはWikipediaに投げる。

Unityのココが凄い

Unityには、初めからオーディオファイルの高速フーリエ変換を行う関数が存在しています。

audio.GetSpectrumData(float[] samples, int channel, FFTWindow window);

これで、チャンネルchannelの*2オーディオファイルのFFT結果をsamplesに入れることが出来ます。samplesのサイズは2の乗数である必要があります。まぁ、1024を使うのが無難っぽい?windowは窓関数というものらしく、なんだかよく分かりませんがノイズを軽減する効果があるみたいです。だいたいFFTWindow.BlackmanHarrisを入れておけばいいと思います。
これで得られるデータは、Nをsamplesのインデックスナンバー・FをAudioSettings.outputSampleRateの値・Qをsamplesのサイズとすると、\frac{NF}{2Q}で与えられる周波数(Hz)です。
ピッチを探すには、samplesの中で最も大きな値を持っているものを探せば良いです。これがおおよそピッチの周波数になるのですが、配列の各要素は飛び飛びの周波数の分布を表しているので、周辺の分布にもピッチが影響している可能性があります。
そこで、なんだかよく分かりませんが魔法の数式を使います。ピッチとして検出したインデックスナンバーをNとし、samples[N](S_{N}と略記します)に\frac{1}{2}((\frac{S_{N+1}}{S_{N}})^2-(\frac{S_{N-1}}{S_{N}})^2)を足します。この式、本気で意味がわからないですが、確かに誤差が抑えられる効果を持っています。
ちなみに参考にしたのはここです。これのほぼ丸パクリになりますが、音程を解析するAnalyzeSound関数を定義します。

private int qSamples = 1024; //配列のサイズ
private float threshold = 0.04f; //ピッチとして検出する最小の分布
private float pitchValue; //ピッチの周波数

private float[] spectrum; //FFTされたデータ
private float fSample; //サンプリング周波数

void AnalyzeSound()
{
	audio.GetSpectrumData(spectrum, 0, FFTWindow.BlackmanHarris);
	float maxV = 0;
	int maxN = 0;
	//最大値(ピッチ)を見つける。ただし、閾値は超えている必要がある
	for (int i = 0; i < qSamples; i++)
	{
		if (spectrum[i] > maxV && spectrum[i] > threshold)
		{
			maxV = spectrum[i];
			maxN = i;
		}
	}

	float freqN = maxN;
	if (maxN > 0 && maxN < qSamples - 1)
	{
		//隣のスペクトルも考慮する
		float dL = spectrum[maxN - 1] / spectrum[maxN];
		float dR = spectrum[maxN + 1] / spectrum[maxN];
		freqN += 0.5f * (dR * dR - dL * dL);
	}
	pitchValue = freqN * (fSample / 2) / qSamples;
}

これを使って、440Hzの正弦波の分布を見ると以下のようになります。
f:id:ibako31:20140206024431j:plain
多少誤差はありますが、まぁまぁ正確な周波数が出ています。

周波数から数値音階への変換

さて、ここからはカラオケ採点として数値を楽に扱うために、周波数から数値音階への変換方法を考えます。
数値音階というのは私の造語です。ここでは以下のように定義します。

数値音階とは、以下の性質を満たす実数である。

  • 110Hzを0とする
  • ある数値音階から1大きい数値音階は、半音上の音階を表す
  • ある数値音階から1小さい数値音階は、半音下の音階を表す

これは何をするためのものかというと、CROSSOのような歌唱線を作るためのものです。
ところで、音階と周波数に関する基礎知識を整理しましょう。

  • 440HzはA(ラ)の音
  • 周波数が2倍になるとちょうど1オクターブ高い音となる

以降、ドレミファソラシドをCDEFGABと表します。上の基礎知識は、つまり110Hz・220Hz・440Hz・880Hz…はAの音ということです。これをそれぞれA1・A2・A3・A4…と定義します。
A1からA2の間には、嬰音(#の付いた音)も考えると12音存在します。この12音は、110Hzから220Hzの間に均等に配置されています。
つまり、x=0をA1・x=1をA#1・x=2をB1…として、yを対応する周波数とすると、y=110\cdot{2}^{\frac{x}{12}}という関係式が出来ます。これを整理すると、x=\frac{12log\frac{y}{110}}{log2}となります。ここで、logは自然対数です。

また、数値音階を素に最も近い音階を文字列で表示できるようにすると便利でしょう。今回は四半音まで判断できるようにしました(たとえば、A1とA#1の間の音はA1+と表します)。

以下がコードになります。

// ヘルツから音階への変換
float ConvertHertzToScale(float hertz)
{
	if (hertz == 0) return 0.0f;
	else return (12.0f * Mathf.Log(hertz / 110.0f) / Mathf.Log(2.0f));
}

// 数値音階から文字音階への変換
string ConvertScaleToString(float scale)
{
	// 12音階の何倍の精度で音階を見るか
	int precision = 2;

	// 今の場合だと、mod24が0ならA、1ならAとA#の間、2ならA#…
	int s = (int)scale;
	if (scale - s >= 0.5) s += 1; // 四捨五入
	s *= precision;

	int smod = s % (12 * precision); // 音階
	int soct = s / (12 * precision); // オクターブ

	string value; // 返す値

	if (smod == 0) value = "A";
	else if (smod == 1) value = "A+";
	else if (smod == 2) value = "A#";
	else if (smod == 3) value = "A#+";
	else if (smod == 4) value = "B";
	else if (smod == 5) value = "B+";
	else if (smod == 6) value = "C";
	else if (smod == 7) value = "C+";
	else if (smod == 8) value = "C#";
	else if (smod == 9) value = "C#+";
	else if (smod == 10) value = "D";
	else if (smod == 11) value = "D+";
	else if (smod == 12) value = "D#";
	else if (smod == 13) value = "D#+";
	else if (smod == 14) value = "E";
	else if (smod == 15) value = "E+";
	else if (smod == 16) value = "F";
	else if (smod == 17) value = "F+";
	else if (smod == 18) value = "F#";
	else if (smod == 19) value = "F#+";
	else if (smod == 20) value = "G";
	else if (smod == 21) value = "G+";
	else if (smod == 22) value = "G#";
	else value = "G#+";
	value += soct + 1;

	return value;
}

さらに、CROSSOみたいに波形が出るような機構を作りました。これについてのコードは省略します。
これを用いて、C2からC3までの正弦波を流しました。
f:id:ibako31:20140206030608j:plain
いい感じだったので、試しに普通の楽曲を流してみました。これは初音ミクの激唱です。
f:id:ibako31:20140206031045j:plain
う~ん、なかなか絶望的な感じ。なお、閾値以上の音が取れていない場合は最下点に持って行っています。
とにかくBGM(特にドラム)が妨害してくる感じなので、ボーカルのみの曲を探しました。
これなんか、解析できないと話にならなさそうですね。やってみました。
f:id:ibako31:20140206031705j:plain
まぁまぁという感じでしょうか。ここまで綺麗なデータでもノイズの影響は出るみたいです。音程はそこそこちゃんと取れていますが、何故かオクターブ違いで取ってしまっている箇所がありますね。JOYSOUNDのアレって普通に起こり得る現象なんですね。

課題

  • ノイズによって波形が乱れる現象について考える

*1:logの底は2で2^10が1024だから、log1000は約10と考える。

*2:確かめていないのでちょっと怪しいが、たぶんそうだろう

5.2 Unityでオーディオファイルを利用する

  1. Unityを用いる
    1. C#について少し復習する
    2. Unityの2D描画について簡単に学ぶ
  2. Unityの音声制御
    1. MIDIを再生させる手段を見つける
    2. 音声のマイク入力手段を見つける
  3. 入力音声の解析
    1. UnityのFFTライブラリを触ってみる
    2. FFTされたデータから音程を確定させるアルゴリズムを考える
  4. MIDIの解析
    1. 第1トラックの楽譜を解析する
    2. MIDIの再生箇所を解析する手段を見つける
  5. 採点のための解析
    1. 全体の点数計算アルゴリズムを考える
    2. ビブラート判定アルゴリズムを考える
    3. しゃくり判定アルゴリズムを考える
    4. タイミング判定アルゴリズムを考える
    5. なめらかさ判定アルゴリズムを考える

UnityでMIDI再生は出来ない!

早速ですが、UnityでMIDI再生は出来ません。└(┐┘)┌ゴミだァ~~~~~
色々と探しましたが、お金を払わない範囲ではどうしようもないようです。

新しい設計

というわけで、カラオケ採点機のための新しい設計を考えました。

  • オーディオファイル(wavとか)を鳴らす
    • 主旋律やテンポなどのデータを取ったファイルをMIDIとして用意、読み込んでwavファイルと同期させる

この設計にすることで、以下の様な利点と欠点が生まれます。

利点
  • 原曲音源でもカラオケが出来る
  • MIDIの解析が楽になる
  • 既存のUnity機能を使って楽に制作できる
欠点
  • キー変更が出来ない
  • テンポ変更が出来ない
  • テンポを完コピしたMIDIを用意しなければ同期ズレが発生する

特にキー変更が出来ないのは痛いですが、仕方がないです。

Unityでオーディオファイルを扱う

MIDIの解析が山場になることが確定しましたが、とりあえずUnityでオーディオファイルを扱う方法についてメモ。
f:id:ibako31:20140202172524j:plain
空のGameObjectを作成し、Add Component -> Audio -> Audio Source。上のようなものがInspectorに出てきます。
音源を対象プロジェクトのAssetsフォルダに追加します。Unityに戻ると自動で同期されます。オーディオファイルの場合、同期に時間がかかる場合もあるみたいです。
f:id:ibako31:20140202173034j:plain
こんな感じにインポート出来るはずです(画像のオーディオファイルは作業時に聞いていたものです)。
先ほど作成したAudio SourceのAudio Clipにドラッグしておきます。

さて、楽しいコーディングです。
Add ComponentからC#スクリプトを加えます。名前はAudioにでもしておきましょう。
以下の様なコードを書きましょう。

using UnityEngine;
using System.Collections;

public class Audio : MonoBehaviour {
	public int start_time;

	// Use this for initialization
	void Start () {
		audio.Play();
		audio.time = start_time;
	}
	
	// Update is called once per frame
	void Update () {
		Debug.Log(audio.time);
	}
}

audio.Play()

このGameObjectに割り当てられたAudio Sourceを再生する。

audio.time

このGameObjectに割り当てられたAudio Sourceの再生位置を設定および読み込みする。

フレームごとに再生時間を出力します。
start_timeを弄ってから再生することで、開始時間を変更できます。
f:id:ibako31:20140202174738j:plain
音質がものすごく劣化するけど、回避方法はあるのかな。

参考文献

OSC, MIDI プラグイン (Unity) – Keijiro Takahashi
オーディオファイルの再生と動機して、MIDIデータから譜面を読み込み何だかんだするライブラリ(SmfLite)があります。ここから新設計の着想を得ました。

keijiro_smflite at test · GitHub.htm
SmfLiteのサンプルです。ここからUnityのAudioについて知見を得ました。

5.1 Unityで波形を表示する

というわけで、もう一つのブログに書いてあった通り、カラオケ採点機を作っていく。
春合宿での発表のためにも、こまめにノート(記事)を取っていくことにした。
記事内で扱う内容は赤文字、既に終わった内容は灰文字で表示する*1

  1. Unityを用いる
    1. C#について少し復習する
    2. Unityの2D描画について簡単に学ぶ
  2. Unityの音声制御
    1. MIDIを再生させる手段を見つける
    2. 音声のマイク入力手段を見つける
  3. 入力音声の解析
    1. UnityのFFTライブラリを触ってみる
    2. FFTされたデータから音程を確定させるアルゴリズムを考える
  4. MIDIの解析
    1. 第1トラックの楽譜を解析する
    2. MIDIの再生箇所を解析する手段を見つける
  5. 採点のための解析
    1. 全体の点数計算アルゴリズムを考える
    2. ビブラート判定アルゴリズムを考える
    3. しゃくり判定アルゴリズムを考える
    4. タイミング判定アルゴリズムを考える
    5. なめらかさ判定アルゴリズムを考える

Unity

たった一日しか触っていないので、かなり適当言ってる可能性があることをご了承ください。

Unityとは、最近流行っているゲーム製作エンジンである。公式サイトはここ
このエンジンの注目すべき特徴は以下の通り。

この中で、特にマルチプラットフォームであることが注目を浴びている。つまり、Unity上であるゲームを完成させれば、それをWindows用にもMac用にもLinux用にもAndroid用にもiOS用にもビルドできるということである。家庭用ゲーム用にもビルド出来るらしい。凄いですね。
使用するために、公式サイトから適当にファイルを落としてインストールしましょう。なんか登録が必要だった気がするのでメールアドレスを用意しましょう。有料版もありますが、無料版で十分だと思うので無料版をインストール。

以下、記事内容はWindows7上のUnity4.3.3f1を想定しています。

Unityでのゲーム製作

インストールできたら起動しましょう。
普通は3Dに飛びつきますが、今回は2Dで事足りるので2Dを選択してNewProjectをCreateしましょう。
f:id:ibako31:20140201231321j:plain
Projectフォルダを設定し、左下のやつを2Dに変えて、Createを押します。チェックボックスは特に何もつけなくて良いです。
f:id:ibako31:20140201231829j:plain
こんな感じの画面が出ると思います。なんか余計なウィンドウが出るかもしれないですが、消してください。
順番に説明しましょう。

Scene

左上のSceneっていう枠のこと。
Unityでは、Project(=ゲーム)はSceneの集まりとして扱う。つまり、爆幻をProjectとして例にすると、Sceneはタイトル画面・実際のゲーム画面、という感じになる。

Game

Sceneの下にあるGameという枠のこと。
実際のゲーム画面を表す。

Hierarchy

真ん中あたりのHierarchyという枠のこと。
現在のSceneに存在するGameObjectの一覧がある。GameObjectはUnityでのゲーム構成要素の末端要素のようなもの。爆幻でいうと、プレイヤーキャラであったりタイトルロゴであったりする。

Projects

Hierarchyの横にあるProjectsという枠のこと。

Favorites

私も何か知らない┐(└┌)┘

Assets

全Scene共通で使えるパーツのこと。画像素材であったり音楽素材であったり共通ソースであったりを突っ込むらしい。

Inspector

一番右にあるInspectorという枠のこと。
GameObjectをクリックすると、ここにその詳細(Components)が出てくる。

初めはMain CameraというGameObjectが存在します。クリックしてみると、以下の様な画面が出ると思います。
f:id:ibako31:20140201232948j:plain
この画面で、InspectorにはMain CameraのComponentsが表示されています。Transform・Camera・GUILayer・Flare Layer・Audio Listenerですね。それぞれに細かい要素があります。たとえば、Transformを見ると、Position・Rotation・Scaleですね。それぞれ、メインカメラの座標・回転・拡縮を表します。これらは、この画面で数値を変更することも可能ですし、C#/JavaScript/Booで書いたスクリプトによって制御することも可能です。今回はC#を使います。

まとめると、こんな感じです。

  • Project(ゲームそのもの)
    • Scene(ゲームの一場面)
      • GameObject(シーンの構成要素。キャラとかロゴとか)
        • Component(オブジェクトの構成要素。座標とか)

Hello World!

上のメニューからGameObject -> Create Empty。
f:id:ibako31:20140201233831j:plain
こんな感じで、空のGameObjectが出来ます。F2か右クリック -> RenameかInspectorから「Hello」にリネームしておきましょう。
Inspectorから「Add Component」を選びます。New Script -> LanguageをC SharpにしてCreate and add。
ここから、別エディタを使ってC#のコードを書いていきます。なんかよく分からんエディタがデフォルトになってるので、適当に使い慣れたエディタに設定変更しましょう。
上のメニューから、Edit -> Preferences -> External Tools -> External Script Editorを好きなものに変更しましょう。私はMicrosoft Visual Studioにしました。
エディタの設定が終わったら、AssetsにあるC#のコードをダブルクリック(または、クリックからInspectorのopen)。VS立ち上がりOSEEEEEE!!!!
f:id:ibako31:20140201234551j:plain
…とりあえず、こんな感じのコードが出るはずです。

using UnityEngine;
using System.Collections;

public class NewBehaviourScript : MonoBehaviour {

	// Use this for initialization
	void Start () {
	
	}
	
	// Update is called once per frame
	void Update () {
	
	}
}

詳しいことは置いといて、メソッドの説明。と言っても、コード内にちゃんと英語で書いてあるのですが…。

void Start()

ゲームの起動時に一度だけ実行される内容。initみたいなもんですね。

void Update()

フレームごとに実行される内容。

というわけで、HelloWorldを書きます。

void Start () {
        Debug.Log("Hello World!");
}

書いたら保存して、Unityに戻ります。
f:id:ibako31:20140201235244j:plain
Inspectorの中身も更新されればOKです。上のメニューから、Window -> Consoleでコンソールウィンドウを出します。
f:id:ibako31:20140201235416j:plain
Clearでコンソールウィンドウの中身をクリアできます。しておきましょう。
Hierarchyの上の方にある再生/一時停止/1フレームごとに再生を表すボタンのうち、再生を押してみましょう。実行にはやや時間がかかります。
f:id:ibako31:20140201235751j:plain
こんな感じになればOK。

正弦波を描画する

まず、カメラを引いておきます。
HierarchyのMainCameraをクリックし、Sceneの上辺りにある十字のマーク(左から2番め)をクリック。Sceneの上でマウスホイールを下に回すと、カメラが引いていきます。
初め、グリッドは10刻みで表示されていますが、引きまくると100刻みになります。とりあえずx座標が-200~200まで入るようにしましょう。その後、真ん中の白い長方形の辺上にある点をドラッグし、カメラ範囲をx座標が-200~200まで入るようにします。
f:id:ibako31:20140202000440j:plain
GameObject -> Create Emptyで新しいGameObjectを作ります。名前はsinにでもしておきましょう。
コンポーネントとして、Effects -> LineRendererを追加します。
LineRendererは、指定した複数座標を直線で繋ぎ、描画します。今回だと正弦波の描画を担当します。
作ったコンポーネントのParametersを開き、Start ColorとEnd Colorに適当な色を設定します。
また、MaterialsのElement0をSprites Defaultにしておきましょう。横のラジオボタンみたいなのを押せばOK。
f:id:ibako31:20140202002547j:plain
こんな感じにしましょう。

コンポーネントとして、再びC#スクリプトを追加します。スクリプト名はsinにでもしておきましょう。
以下のようにコードを書いてください。

using UnityEngine;
using System.Collections;

public class sin : MonoBehaviour {
	public LineRenderer lr;
	private int theta;
	private int count;

	// Use this for initialization
	void Start () {
                theta = 0;
                count = 0;
                lr.SetVertexCount(count);
	}
	
	// Update is called once per frame
	void Update () {
                if(count > 400) count = 0;
                count++;
                lr.SetVertexCount(count);
                lr.SetPosition(count - 1, new Vector3(-200 + count, 80 * Mathf.Sin(theta * Mathf.PI / 180), 0));
                theta++;
	}
}

説明が必要そうなものだけ、順番に説明しましょう。

public LineRenderer lr;

publicにするとソース中で操るコンポーネントを指定することが出来ます。
このコードを保存した後、InspectorのSinを見てください。
f:id:ibako31:20140202003252j:plain
こんな感じで、lrにコンポーネントを指定することが出来ます。
この上に、先ほど作ったLineRendererのコンポーネントをドラッグしてみましょう。すると、それが割り当てられるはずです。
privateだと、この項目は出てきません。

private int theta;

描く正弦波のラジアンを表します。

private int count;

実行からの時間を表します。ただし、400フレーム周期です。

lr.SetVertexCount(int n);

LineRendererの頂点数を指定します。

lr.SetPosition(int index, Vector3 pos)

LineRendererの第index頂点の座標を設定します。

さて、再生してみましょう。以下の様な正弦波がループしていれば成功です。
f:id:ibako31:20140202003910j:plain

*1:C#の復習についてはパスします(時間ないので)