技術的なやつ

技術的なやつ

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にはランニングステータスは適用されていないようである