29
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

KLab EngineerAdvent Calendar 2018

Day 19

Stardard MIDI Fileを解析して音ゲーの譜面を作る話

Last updated at Posted at 2018-12-17

この記事は、KLab Engineer Advent Calendar 2018 19日目の記事です。

Standard MIDI File とは

一言で表すなら、「どの音符をどのタイミングで鳴らすか」等の演奏に関する全ての情報が入っているファイルです。
これだけ聞くと音ゲーの譜面みたいですね!その通りです!

この記事ではMIDIファイルから音ゲーに使えそうな譜面データに変換するプログラムを書きつつ解説します。
レーンに沿って流れてくるタイプの音ゲーの譜面を作るのにとても有効な手法です。

譜面用構造体の定義

MIDI解析の前準備としてノーツイベントとBPMイベントの構造体を定義します。
実際の譜面データになるものです。

public enum NoteType
{
  Normal,      // 通常ノーツ
  LongStart,   // ロング開始
  LongEnd,     // ロング終端
}

public struct NoteData
{
  public int eventTime;  // ノーツタイミング(ms)
  public int laneIndex;  // レーン番号
  public NoteType type   // ノーツの種類
}

public struct TempoData
{
  public int eventTime;  // BPM変化のタイミング(ms)
  public float bpm;      // BPM値
  public float tick      // tick値
}

MIDIファイル構成

データ構造ですが、大きく分けて
・ヘッダチャンク
・トラックチャンク
の2部で構成されています。

ヘッダチャンク

ヘッダチャンク バイト数 補足
チャンクID 4byte チャンクID "MThd" で固定
データ長 4byte 続くチャンクのデータ長 "6" で固定
フォーマット 2byte フォーマット
トラック数 2byte 後のトラックチャンクの個数
分能値 2byte タイムベース 大体480

トラックチャンク

トラックチャンク バイト数 補足
チャンクID 4byte チャンクID  "MTrk" で固定
データ長 4byte データ部のサイズ
データ部 可変 メインのデータ部分
ここにノートイベント等の情報が格納

こちらを構造体にします

チャンクデータ用構造体
/// <summary>
/// ヘッダーチャンク情報を格納する構造体
/// </summary>
public struct HeaderChunkData
{
    public byte[] chunkID;      // チャンクのIDを示す(4byte)
    public int dataLength;      // チャンクのデータ長(4byte)
    public short format;        // MIDIファイルフォーマット(2byte)
    public short tracks;        // トラック数(2byte)
    public short division;      // タイムベース MIDI独自の時間の最小単位をtickと呼び、4分音符あたりのtick数がタイムベース 大体480(2byte)
};

/// <summary>
/// トラックチャンク情報を格納する構造体
/// </summary>
public struct TrackChunkData
{
    public byte[] chunkID;      // チャンクのIDを示す(4byte)
    public int dataLength;      // チャンクのデータ長(4byte)
    public byte[] data;         // 演奏情報が入っているデータ
};

チャンク解析

ここからは、実際の解析コードを交えて解説していきます。

MIDILoader.cs

    public List<NoteData> noteList = new List<NoteData>();
    public List<TempoData> tempoList = new List<TempoData>();

    public void LoadMIDI(string fileName)
    {
            // リスト初期化
            noteList.Clear();
            tempoList.Clear();

            using (var stream = new FileStream(fileName, FileMode.Open, FileAccess.Read))
            using (var reader = new BinaryReader(stream))
            {

MIDIファイルはバイナリデータのため、バイナリで読み込んでいきます。

ヘッダチャンク解析

まずはヘッダチャンクの解析から。

                /* ヘッダチャンク侵入 */
                var headerChunk = new HeaderChunkData();

                // チャンクID
                headerChunk.chunkID = reader.ReadBytes(4);

                // 自分のPCがリトルエンディアンならバイト順を逆に
                if (BitConverter.IsLittleEndian)
                {
                    // ヘッダ部のデータ長(値は6固定)
                    var byteArray = reader.ReadBytes(4);
                    Array.Reverse(byteArray);
                    headerChunk.dataLength = BitConverter.ToInt32(byteArray, 0);
                    // フォーマット(2byte)
                    byteArray = reader.ReadBytes(2);
                    Array.Reverse(byteArray);
                    headerChunk.format = BitConverter.ToInt16(byteArray, 0);
                    // トラック数(2byte)
                    byteArray = reader.ReadBytes(2);
                    Array.Reverse(byteArray);
                    headerChunk.tracks = BitConverter.ToInt16(byteArray, 0);
                    // タイムベース(2byte)
                    byteArray = reader.ReadBytes(2);
                    Array.Reverse(byteArray);
                    headerChunk.division = BitConverter.ToInt16(byteArray, 0);
                }
                else
                {
                    // ヘッダ部のデータ長(値は6固定)
                    headerChunk.dataLength = BitConverter.ToInt32(reader.ReadBytes(4), 0);
                    // フォーマット(2byte)
                    headerChunk.format = BitConverter.ToInt16(reader.ReadBytes(2), 0);
                    // トラック数(2byte)
                    headerChunk.tracks = BitConverter.ToInt16(reader.ReadBytes(2), 0);
                    // タイムベース(2byte)
                    headerChunk.division = BitConverter.ToInt16(reader.ReadBytes(2), 0);
                }

MIDIファイルはビッグエンディアン方式でデータが格納されているため、CPUに応じてバイトオーダー変換を行う必要があります
C#では BitConverter.IsLittleEndian を用いてエンディアンの判定を行います。

トラックチャンク解析

次はトラックチャンクの解析です

                /* トラックチャンク侵入 */
                var trackChunks = new TrackChunkData[headerChunk.tracks];

                // トラック数ぶん
                for (int i = 0; i < headerChunk.tracks; i++)
                {
                    trackChunks[i] = new TrackChunkData();

                    // チャンクID
                    trackChunks[i].chunkID = reader.ReadBytes(4);

                    // 自分のPCがリトルエンディアンなら変換する
                    if (BitConverter.IsLittleEndian)
                    {
                        // トラックのデータ長読み込み(値は6固定)
                        var byteArray = reader.ReadBytes(4);
                        Array.Reverse(byteArray);
                        trackChunks[i].dataLength = BitConverter.ToInt32(byteArray, 0);
                    }
                    else
                    {
                        trackChunks[i].dataLength = BitConverter.ToInt32(reader.ReadBytes(4), 0);
                    }

                    // データ部読み込み
                    trackChunks[i].data = reader.ReadBytes(trackChunks[i].dataLength);

                    // データ部解析
                    TrackDataAnalysis(trackChunks[i].data, headerChunk);
                }

補足
チャンクIDやデータ部をエンディアン変換しないのは1バイトずつで表現されているため。

トラックデータ部解析

MIDI解析のメインです。ここからノーツや速度変化のイベントを抽出します。

データ部の構成は
前回のイベントからの経過時間(ms) デルタタイム
次に イベントデータ
この2つが繰り返し格納されています。

こちらもコードを交えながら解説していきたいと思います。

前準備
    /// <summary>
    /// トラックデータ解析
    /// </summary>
    public void TrackDataAnalysis(byte[] data, HeaderChunkData headerChunk)
    {
            uint currentTime = 0;                    // デルタタイムを足していく、つまり現在の時間(ms)(ノーツやソフランのイベントタイムはこれを使う)
            byte statusByte = 0;                     // ステータスバイト
            bool[] longFlags = new bool[128];    // ロングノーツ用フラグ

            // データ分
            for (int i = 0; i < data.Length;)
            {

デルタタイム部解析

デルタタイムは 可変長数値表現 を用いてデータ格納されています。

  • 1バイトをビット単位に分解し、上位1ビットと下位7ビットに分ける
  • 下位7ビットを数値として表現
  • 上位1ビットが
    • 1の場合、次の1バイトも可変長数値として連結し、同じように処理する
    • 0の場合、終了

ここはコードを見たほうが分かりやすいかもしれません。

可変長数値表現をint型に変換する
                // デルタタイム格納用
                uint deltaTime = 0;

                while (true)
                {
                    var tmp = data[i++];

                    // 下位7bitを格納
                    deltaTime |= tmp & (uint)0x7f;

                    // 最上位1bitが0ならデータ終了
                    if ((tmp & 0x80) == 0) break;

                    // 次の下位7bit用にビット移動
                    deltaTime = deltaTime << 7;
                }
                // 現在の時間にデルタタイムを足す
                currentTime += deltaTime;

イベント部解析

構造は単純なのですが、イベントの種類が無駄に多い。

何のイベントかを表す ステータスバイト
次に イベントに沿ったデータ
の2点構成。

まずはステータスバイトから

ステータスバイトを保存
                /* ランニングステータスチェック */
                if (data[i] < 0x80)
                {
                    // ランニングステータス適応(前回のステータスバイトを使いまわす)
                }
                else
                {
                    // ステータスバイト保存
                    statusByte = data[i++];
                }

ここで急にでてきた ランニングステータス ですが、
名前の割に難しいことはなく、「前回のイベントと同じだから使いまわしてね!」という意味です。
変数を外に出したのはこの使いまわしの為です。

そして、ステータスバイトの値から何のイベントかを特定しデータを解析します。
ステータスバイトのイベント採番や、データの情報はこちらのサイトで詳細に解説されているのでガッツリ参考にさせていただきました。
2020/4/9追記 サイトが見れなくなっているっぽいので代替リンクを追加しました。

ステータスバイトからイベントを特定し、譜面に使えそうなイベントならデータ解析

                // ステータスバイト後のデータ保存用
                byte dataByte0, dataByte1, dataLength;

                /* MIDIイベント(ステータスバイト0x80-0xEF) */
                if (statusByte >= 0x80 && statusByte <= 0xef)
                {
                    switch (statusByte & 0xf0)
                    {
                        /* チャンネルメッセージ */

                        case 0x80:  // ノートオフ
                            // どのキーが離されたか
                            dataByte0 = data[i++];
                            // ベロシティ値
                            dataByte1 = data[i++];

                            // 前のレーンがロングノーツなら
                            if (longFlags[dataByte0])
                            {
                                // ロング終点ノート情報生成
                                var note = new NoteData();
                                note.eventTime = (int)currentTime;
                                note.laneIndex = (int)dataByte0;
                                note.type = NoteType.LongEnd;

                                // リストにつっこむ
                                noteList.Add(note);

                                // ロングノーツフラグ解除
                                longFlags[note.laneIndex] = false;
                            }
                            break;
                        case 0x90:  // ノートオン(ノートオフが呼ばれるまでは押しっぱなし扱い)
                            // どのキーが押されたか
                            dataByte0 = data[i++];
                            // ベロシティ値という名の音の強さ。ノートオフメッセージの代わりにここで0を送ってくるタイプもある
                            dataByte1 = data[i++];

                            {
                                // ノート情報生成
                                var note = new NoteData();
                                note.eventTime = (int)currentTime;
                                note.laneIndex = (int)dataByte0;
                                note.type = NoteType.Normal;
                                // 独自でやっている。ベロシティ値が最大のときのみロングの始点とする
                                if (dataByte1 == 127)
                                {
                                   note.type = NoteType.LongStart;
                                   // ロングノーツフラグセット
                                   longFlags[note.laneIndex] = true;
                                }
                                // ノートオフイベントではなく、ベロシティ値0をノートオフとして保存する形式もあるので対応
                                if (dataByte1 == 0)
                                {
                                    // 同じレーンで前回がロングノーツ始点なら
                                    if (longFlags[note.laneIndex])
                                    {
                                        note.type = NoteType.LongEnd;
                                        // ロングノーツフラグ解除
                                        longFlags[note.laneIndex] = false;
                                    }
                                }

                                // リストにつっこむ
                                noteList.Add(note);
                            }
                            break;
                        case 0xa0:  // ポリフォニック キープレッシャー(鍵盤楽器で、キーを押した状態でさらに押し込んだ際に、その圧力に応じて送信される)
                            i += 2; // 使わないのでスルー
                            break;
                        case 0xb0:  // コントロールチェンジ(音量、音質など様々な要素を制御するための命令)
                            // コントロールする番号
                            dataByte0 = data[i++];
                            // 設定する値
                            dataByte1 = data[i++];

                            // ※0x00-0x77までがコントロールチェンジで、それ以上はチャンネルモードメッセージとして処理する
                            if (dataByte0 < 0x78)
                            {
                                // コントロールチェンジ
                            }
                            else
                            {
                                // チャンネルモードメッセージは一律データバイトを2つ使用している
                                // チャンネルモードメッセージ
                                switch (dataByte0)
                                {
                                    case 0x78:  // オールサウンドオフ
                                        // 該当するチャンネルの発音中の音を直ちに消音する。後述のオールノートオフより強制力が強い。
                                        break;
                                    case 0x79:  // リセットオールコントローラ
                                        // 該当するチャンネルの全種類のコントロール値を初期化する。
                                        break;
                                    case 0x7a:  // ローカルコントロール
                                        // オフ:鍵盤を弾くとMIDIメッセージは送信されるがピアノ自体から音は出ない
                                        // オン:鍵盤を弾くと音源から音が出る(基本こっち)
                                        break;
                                    case 0x7b:  // オールノートオフ
                                        // 該当するチャンネルの発音中の音すべてに対してノートオフ命令を出す
                                        break;
                                    /* MIDIモード設定 */
                                    // オムニのオン・オフとモノ・ポリモードを組み合わせて4種類のモードがある
                                    case 0x7c:  // オムニモードオフ
                                        break;
                                    case 0x7d:  // オムニモードオン
                                        break;
                                    case 0x7e:  // モノモードオン
                                        break;
                                    case 0x7f:  // モノモードオン
                                        break;
                                }
                            }
                            break;

                        case 0xc0:  // プログラムチェンジ(音色を変える命令)
                            i += 1;
                            break;

                        case 0xd0:  // チャンネルプレッシャー(概ねポリフォニック キープレッシャーと同じだが、違いはそのチャンネルの全ノートナンバーに対して有効となる)
                            i += 1;
                            break;

                        case 0xe0:  // ピッチベンド(ウォェーンウェューンの表現で使う)
                            i += 2;
                            // ボルテのつまみみたいなのを実装する場合、ここの値が役立つかも
                            break;
                    }
                }

                /* システムエクスクルーシブ (SysEx) イベント*/
                else if(statusByte == 0x70 || statusByte == 0x7f)
                {
                    dataLength = data[i++];
                    i += dataLength;
                }

                /* メタイベント*/
                else if(statusByte == 0xff)
                {
                    // メタイベントの番号
                    byte metaEventID = data[i++];
                    // データ長
                    dataLength = data[i++];

                    switch (metaEventID)
                    {
                        case 0x00:  // シーケンスメッセージ
                            i += dataLength;
                            break;
                        case 0x01:  // テキストイベント
                            i += dataLength;
                            break;
                        case 0x02:  // 著作権表示
                            i += dataLength;
                            break;
                        case 0x03:  // シーケンス/トラック名
                            i += dataLength;
                            break;
                        case 0x04:  // 楽器名
                            i += dataLength;
                            break;
                        case 0x05:  // 歌詞
                            i += dataLength;
                            break;
                        case 0x06:  // マーカー
                            i += dataLength;
                            break;
                        case 0x07:  // キューポイント
                            i += dataLength;
                            break;
                        case 0x20:  // MIDIチャンネルプリフィクス
                            i += dataLength;
                            break;
                        case 0x21:  // MIDIポートプリフィックス
                            i += dataLength;
                            break;
                        case 0x2f:  // トラック終了
                            i += dataLength;
                            // ここでループを抜けても良い
                            break;
                        case 0x51:  // テンポ変更
                            {
                                // テンポ変更情報リストに格納する
                                var tempoData = new TempoData();
                                tempoData.eventTime = (int)currentTime;

                                // 4分音符の長さをマイクロ秒単位で格納されている
                                uint tempo = 0;
                                tempo |= data[i++];
                                tempo <<= 8;
                                tempo |= data[i++];
                                tempo <<= 8;
                                tempo |= data[i++];

                                // BPM割り出し
                                tempoData.bpm = 60000000 / (float)tempo;

                                // 小数点第1で切り捨て処理(10にすると第一位、100にすると第2位まで切り捨てられる)
                                tempoData.bpm = Mathf.Floor(tempoData.bpm * 10) / 10;

                                // tick値割り出し
                                tempoData.tick = (60 / tempoData.bpm / headerChunk.division * 1000);

                                // リストにつっこむ
                                tempoList.Add(tempoData);
                            }
                            break;
                        case 0x54:  // SMTPEオフセット
                            i += dataLength;
                            break;
                        case 0x58:  // 拍子
                            i += dataLength;
                            // 小節線を表示させるなら使えるかも
                            break;
                        case 0x59:  // 調号
                            i += dataLength;
                            break;
                        case 0x7f:  // シーケンサ固有メタイベント
                            i += dataLength;
                            break;
                    }
                }
            }

これでノーツイベントとBPM変更イベントを格納し、音ゲーの基本的な譜面データが揃いました。
あともう一歩です。

イベントタイム再計算

MIDIファイルのイベントデルタタイムは全て最初に設定されたテンポから計算されているため、
各イベント時間をその時に設定されている状態のテンポで計算しなおす必要があります。

MIDILoader.cs
    void ModificationEventTimes()
    {
        // 一時格納用(計算前の時間を保持したいため)
        var tempTempoList = new List<TempoData>(tempoList);

        // テンポイベント時間修正
        for (int i = 1; i < tempoList.Count; i++)
        {
            TempoData tempo = tempoList[i];

            int timeDifference = tempTempoList[i].eventTime - tempTempoList[i - 1].eventTime;
            tempo.eventTime = (int)(timeDifference * tempoList[i - 1].tick) + tempoList[i - 1].eventTime;

            tempoList[i] = tempo;
        }

        // ノーツイベント時間修正
        for (int i = 0; i < noteList.Count; i++)
        {
            for (int j = tempoList.Count - 1; j >= 0; j--)
            {
                if (noteList[i].eventTime >= tempTempoList[j].eventTime)
                {
                    NoteData note = noteList[i];

                    int timeDifference = noteList[i].eventTime - tempTempoList[j].eventTime;
                    note.eventTime = (int)(timeDifference * tempTempoList[j].tick) + tempoList[j].eventTime;   // 計算後のテンポ変更イベント時間+そこからの自分の時間
                    noteList[i] = note;
                    break;
                }
            }
        }
    }

以上でMIDIファイルから譜面データ( noteList , tempoList )を作るコードは終了となります。

本当は譜面データを元にUnity上で動かすまで書きたかったのですが、あまりに長くなってしまう(あと間に合わなかった…)ので今回はここで閉めさせていただきます…ゴメンナサイ

終わりに

めちゃ長くなってしまいましたが、最後までお読みいただきありがとうございました。
この記事が音ゲーを作ってみたいと思う人への手助けになれば私は幸せです。

参考

スタンダードMIDIファイル
The MIDI File Format
MIDIファイル解析ソースコードの紹介
C言語でMIDI(SMF)データを読んでみる!
JavaScriptで可変長数値表現を使う

29
17
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
29
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?