たつぷりの調査報告書

博士後期課程(理学)の学生が趣味でUnityやBlenderで遊ぶブログです。素人が独学で勉強した際の忘備録です。

UnityでMidiデータのテンポに合わせてオブジェクトの生成速度を制御する

どうもこんにちは、たつぷりです。今回は以下の投稿の続きです。

tatsupuri.hatenablog.com

前の投稿の範囲で作成したプロトタイプでは曲のテンポはこちらで指定していました。つまり曲のテンポが一定の曲しか扱うことができなかったのですが、Midiはメタイベント内にテンポの情報が格納されているらしいということを知ったので、Midiデータを読み込む際テンポの情報を取り込んで自動で曲の速さを制御できるようにすることを今回の目標にしたいと思います。

テンポの制御

練習用データ

テストとして次の楽譜をMuse Scoreで作成した。 f:id:Tatsupuri:20201017201252p:plain

つまり4小節の間テンポが80 [四分音符/分]で、その後テンポが速くなり、100 [四分音符/分]になる。まずは簡単のためこれを再現できることを目標にした。完成品は以下の動画。

youtu.be

やったこと

前回に引き続き、SMFを読み取る部分のプログラムは

github.com

を参考にした。Keijiroさんに感謝。ただこのプロジェクトにおいてMidiからデータを取得するMidiFileDeserializer.csスクリプトではテンポは手で与えていてファイルから読み取っていなかったので、その部分を自分で書くことにした。

メタイベント

テンポの情報はメタイベントとして表現されている。今回は自分でテストデータを作って信号を分析したのと同時に

www.hikari-ongaku.com

を参考にした。

結局、テンポ情報のメタデータFF 51 03 xxxxxx というように表現されている。FFメタデータを表す。51がテンポの情報であることを示す。03がデータの長さを指定しており、続く実際のテンポの値が3バイトで表現されることを表している。xxxxxxが3バイトのテンポ情報で単位は[μs/四分音符]である。

したがって通常のテンポの単位である [四分音符/分]に変換するには

tempo = 60000000/xxxxxx

を計算すれば良いことになる。

以上のことから実際にやることは、

  1. FFを検出したら、続く1バイトの情報からテンポ情報かどうかを判定する

  2. テンポ情報の場合は続くデータ長の情報を飛ばして(3バイトということはあらかじめわかっているので)、3バイトのテンポ情報を取得する

  3. メタイベントがテンポ情報でなければ続くデータ長の情報を読み取り、その長さの分飛ばす

  4. 得られたテンポ情報を用いてノートの生成スピードを制御する

の流れである。

メタデータの取得

メタデータの取得に関しては、今回参考にさせてもらっているMidiFileDeserializer.csにおいてはReadTrack メソッドにおいてStatus byteをstat変数に格納している。つまりstat == 0xffu が真のとき、そこから続くデータ列がメタデータであることを表している。

もとのプログラムではメタデータは使用しないためその分のデータを飛ばすプログラムが書かれているのでそこを書き換えて必要な情報を得ることにする。以下のスクリプトは元のプログラムMidiFileDeserializer.csに含まれるif文の中身を書き換えている。

if (stat == 0xffu)//メタイベントを表す。以下が元プログラムの書き換え
{ 
    var meta = reader.ReadByte(); 
    if (meta == 0x51u)//テンポ情報
    { 
         var dataLengh = reader.ReadMultiByteValue();
        //データ長の情報。ここでは03が入るはず。(テンポの情報は3バイトであらわされる) 

         var data = reader.ReadBEUInt24();
        //テンポのデータ。単位は[ms/qn]。通常表記のテンポ[qn/min]に直すには、逆数に60000000をかければ良い。 
         var tmp = Math.Round(60000000 / (float)data); 
        //ここで用いている定義だと出力するとき小数の情報が落ちてしまうので四捨五入しておく。
         events.Add(new MidiEvent 
         { 
             time = ticks, 
             status = stat, 
             data1 = meta, 
             data2 = (byte)tmp 
          }); 
    } 
    else 
    { 
         reader.Advance(reader.ReadMultiByteValue());
         //reader.ReadMultiByteValue()で続くデータ長さを取得。その分Advanceで送っている。 
     } 
 }

ただしこのスクリプト内で用いたreader.ReadBEUInt24() メソッドは元のプログラムでは存在していない。テンポを表す3バイトのデータを読み取るためにそれ用のメソッドを新しく用意した。

MidiDataStreamReader クラスに以下のようにしてこのメソッドを追加した。

public uint ReadBEUInt24()  
        { 
            uint b1 = ReadByte(); 
            uint b2 = ReadByte(); 
            uint b3 = ReadByte(); 
            return b3 + (b2 << 8) + (b1 << 16); 
        }

以上のように書き換えたことで、テンポの変更指示があったときは、その時の時間 [ticks]におけるテンポ[四分音符/分]を得ることができるようになった。

テンポの自動制御

自分のプロジェクトでは入力されたテンポに応じてノートの生成速度を変えている。これはどのように実装しているかというと、詳しくは以前の投稿にまとめてあるが、以下のようにする。 四分音符一の長さに相当するTick数であるticksPerQuarterNoteと今取得したテンポの情報tempo があれば、ゲーム内で経過した時間(ここではrealTime [秒]とする)を以下の計算でtick単位に変換することができた。

realTime[秒] * ticksPerQuarterNote[ticks/四分音符] * tempo[四分音符/分] / 60[秒/分] = realTimeInTicks[ticks]

つまりUpdate関数内でTime.deltaTimeを足していきrealTime[秒]を求め、上の計算によって単位をticks 変換した上でmidiEvent.timeを超えたかどうかでそのイベントを見るかどうか決めれば良いのである。 基本的には以上の方針で実装できるが、基本的なことだが以下の注意をしておく。自分は最初これを見落としていて曲が数小節ずれる現象に遭遇した。

分解能の問題

最初自分はTime.deltaTimeを足していってrealTime[秒]を計算して、この値をticks単位に変換して用いていた。しかし少し考えればわかることであるが、このrealTime[秒]は徐々に大きくなってきてくるのでテンポが変わった際に変換の結果がTickの分解能よりも大きくなってしまっていくつかの複数の異なる時間のイベントを同時とみなしてしまう現象が生じる。

これを避けるために時間のカウントを秒でカウントするのではなく、tickでカウントすることにする。つまり、各Time.deltaTime[s]を[ticks]に変換して1フレーム間に経過するtick数を計算してこれを足し上げていき、この値を各イベントが起こった時間[ticks]と比較することでノートのオブジェクトを生成するタイミングを決める。

このように すると上の分解能の問題を回避できる。

以上の方針で実装すると、Muse Scoreで作成した楽譜通りの曲のテンポに合わせてノートオブジェクトの生成速度を自動で制御できるようになった。

実際の曲で試す

テンポの変化が何度かある実際の曲で上のプログラムを試してみた結果を以下に示す。

youtu.be

画面のInspector ViewでNote GeneratorのTempo を見ると、テンポを取得している様子を見ることができる。また実際それに合わせてノートの生成速度が制御されていることが確認できる。