たつぷりの調査報告書

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

MIDIって何?の状態からUnityを使って音ゲーを作ってみる

こんにちは、たつぷりです。今回の記事は少し長くなってしまいましたが、MIDI形式の音楽とUnityを使ってリズムゲームを作ってみたという話です。私はMIDIって何のことがほとんど知らない状態だったのですが、いろいろあってこれを機に少し調べてみたので、忘備録としてこの記事にまとめておくことにしました。

目標

今回目標にするのは、次の動画のようなリズムゲーム(のプロトタイプ)を作成することである。リズムゲームといってもこの動画の段階ではまだUIを作っていないのでゲームとしての体はなしていない。

youtu.be

ではここでは何をしているのかというと、以下のようなことである。

  • Muse Scoreで自作したリズムパターンをSMFで出力し、Unityで読み込む

  • SMFの情報に合わせてリズムを表すゲームオブジェクトを生成、移動させる

  • 音色(上の動画では、バスドラム、スネア、クローズドハイハット)に合わせてラインを分ける

これを曲に合わせて実行することで典型的なリズムゲーム感を出すことを目標にしている。

なぜMIDIを使ってみようと思ったのか?

これに関しては強い信念があるわけではないが、ある日Unity JapanのYouTubeチャンネルで以下のリンクの動画を見た。 www.youtube.com

これを見た筆者は、それまでMIDIって名前を聞いたことがあるくらいだったが、MIDIの音を発するタイミングや消すタイミングの情報などで音楽を表現する仕組みを面白いと感じた。その記憶があってふと音ゲーを作ってみたいなあと思った際の計画段階で、MIDIを使ってみる方針を固めた。

さらに言うならば、筆者は音楽的な才能と教養が皆無なので編曲したりましてや自分で曲を作ったりすることは不可能なので、楽譜のリソースが多いに越したことはないと思っていた。その観点で調べていた時にMuse Scoreには有志の人たちが楽譜を上げてくれていて、それをMIDI形式でダウンロードできるということを知ったのでMIDIという選択肢は固いものとなった。

今思うといろいろ突っ込みどころはあるが、このような理由でMIDIとUnityでリズムゲームを作成することにした。

Unityと標準MIDIファイル(SMF)

UnityではMIDIは標準サポートしていない。なのでMIDIから発音のタイミングなどの情報を得るためには本来は自分でImporterなどを作る必要がある。しかしこれに関しては以下のGitHubリポジトリで非常にわかりやすく実現されているので、このプロジェクトで勉強しつつ、改造して使わせていただきました。Keijiroさんに感謝。

github.com

本当は一から勉強する必要があると思うが、今回はプロトタイプを作るだけなので手早く最小限の理解を目指す。具体的に何をするかというと、(この上なく幼稚なやり方かもしれないが)Muse Scoreでテスト用の簡単な音源を作成し、上のリポジトリのプロジェクトを参考にしながらMIDIイベントの情報を出力する。そして得られた出力のパターンと自分で作ったテスト用の楽譜を照らし合わせ解析を行う。

なお、Muse Scoreの使い方に関しては補足記事に少しだけまとめた。 tatsupuri.hatenablog.com

今回の目的で必要な情報をだけを得ることに限れば、個人的にはこのように実験的に理解する方が早いのでこの方法を用いた。

Unity側の準備

上でも述べた、MidiAnimiationTrack のプロジェクトを使わせてもらう。

まずMidiFileAssetImporter.csの中身を確認してみると、ドラックアンドドロップしてきた標準MIDIファイルをMidiFileDeserializer クラスのLoadメソッドでSMFのルールに従って情報を読み取っている。

MidiFileDeserializer.LoadMidiFileAsset クラスのインスタンスを返す。これはtracks という配列を示す。これは読み込んだSMFファイルがトラックごとに分けられ、各要素にそのデータが格納されています。 各トラック(つまりtracksの各要素)は、MidiAnimationAsset  クラスである。

今回自分が必要とする情報はMidiAnimiationAssettemplate フィールドである。

具体的に今回自分が欲しい情報は、

  • track.template.duration  : 曲の長さの情報。単位はticks。

  • track.template.ticksPerQuarterNote  : 四分音符一個分の長さで経過するticks

  • track.template.events  : MIDIイベント、すなわち発音・消音、音程、音色などの情報が含まれている。

などである。これらを後でUpdate関数などから呼べるようにMidiAnimiationAsset クラスをパブリックに書き換えさせていただく。

SMFの解析

SMFについてほとんど知らないが、今回必要な範囲において使われているデータを実験的に理解することを試みる。 やったことはMuse Scoreでテスト用のMIDIデータを用意し(Muse Scoreでテスト用MIDIを作成する)、UnityのコンソールにMIDIイベントを出力し、楽譜との対応関係から必要な情報を読み出すことである。

midiEventSet = track.template.events;

 foreach (MidiEvent midiEvent in midiEventSet) 
{ 
    Debug.Log(midiEvent.time+","+ midiEvent.status + ","+ midiEvent.data1 + ","+ midiEvent.data2); 
}

上記のようにしてMIDIイベントを出力し、その数字の配列と自分が作成した楽譜を見比べて、必要な情報を読み取るのである。ここで今回の目標(音ゲーの作成)のために必要な情報を整理しておくと以下のようなものである。

  • 音色:ドラムの種類の識別に使う(スネア、クローズドハイハット、・・・など)

  • 音階:ピアノなど音階の識別

  • 発音(消音):どこで音を出す(消す)かの情報。今回はまず発音のタイミングでオブジェクトを生成するように設計するので基本には発音の情報のみで十分。

使ったテストデータは以下の二つである。 f:id:Tatsupuri:20201015052450p:plain f:id:Tatsupuri:20201015052507p:plain

以下が実際の出力から分析した結果である。

ピアノ

xx,144,yy,80  = xxの時間にyyの音(ピアノ)を出す xx,144,yy,0  = xxの時間にyyの音(ピアノ)を消す

yy = 60 がト音記号の下第一線の「ド」 この値は1増えるごとに半音上がる

つまり、ドレミファミレド =60,62,64,65,64,62,60 ということになる。

ドラム

xx,153,yy,80  = --の時間にyyの音(ドラム)を出す xx,153,**,0  = --の時間にyyの音(ドラム)を消す

36がバスドラム 42がクローズドハイハット 40が(エレクトリック)スネア

同時に複数の音を発音するときは単純に、同じ時間のデータを複数用意することで表現されている。

音ゲーにする

以上で得られた結果から実際に音ゲー風にしていく。音ゲーの構成要素を整理してみると以下のようになる。

  • 楽譜の発音のタイミングに合わせて、オブジェクトを生成する

  • 生成したオブジェクトを動かす

  • 音色などに応じて生成する位置を変える(つまりスネアのライン、ハイハットのライン、・・・などと分けていく)

  • 音源と同期させる

  • オブジェクトが溢れないようにどこかで消滅させる

音符(ゲームオブジェクト)の生成

発音のタイミングでオブジェクトを生成すること自体はそこまで難しくない。なぜなら先ほど見たようにmidiEvent.data2が80であるときに発音するので、時間発展に従って配列を読んでいって midiEvent.data2を調べてその結果に応じてInstantiateでオブジェクトを生成していけばよい。

ここで少し注意が必要なのは、時間経過に応じて処理するときはUpdateの中に処理を書いていくがUpdateで経過する時間はTime.deltaTimeである。そのため曲のテンポで指定される速さと実際の時間経過を合わせる必要があるのだ。

midiEvent.timeで取得できるのはそのイベントが何tickで起こったかという情報である。また、四分音符の長さで経過するtick数はtrack.template.ticksPerQuarterNote で取得している。ここで、曲テンポが指定されていれば一分間に四分音符が何個入るかという情報も得られる。

これらの情報を用いればゲーム内で経過した時間(ここではrealTime [秒]とする)をtick単位に変換することができる。思想は以下のような計算である。

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

つまりUpdate関数内でTime.deltaTimeを足していきrealTime[秒]を求め、上の計算によって単位をticks 変換した上でmidiEvent.timeを超えたかどうかでそのイベントを見るかどうか決めれば良いのである。

このようにして実時間とテンポで指定される曲の速さを同期させて、オブジェクトを生成させることができる。

音色(音階)に応じた生成位置の変更

上で分析したようにドラムの音色の情報はmidiEvent.data1から読み取れるのであった。なのでこの値の違いに応じてInstantiate させる時のpositionの情報を変えればよいのである。例えば自分は次のように実装した。

void NoteSet(float time,int tone) 
    {
        if (tone == 36)//bass
        {
            Instantiate(notePrefab, new Vector3(1.5f, 0, startPoint), Quaternion.identity);
        }
        else if (tone == 40)//snare
        {
            Instantiate(notePrefab, new Vector3(0, 0, startPoint), Quaternion.identity);
        }
        else if (tone == 42) //closed high hat
        {
            Instantiate(notePrefab, new Vector3(-1.5f, 0, startPoint), Quaternion.identity);
        }
    }

音符(ゲームオブジェクト)を動かす

これもUnityの基本である。Updateごとに座標を動かしていけば良い。この時、動かすスピードをインスペクターから調整しておける等にしておくとよい。

音源と同期させる

音源と同期させることに関しては、ここではそこまで高尚なことはせずに音楽のスタート位置の調整にとどめる。これがなぜ必要なのかというと次のような理由がある。

音符のゲームオブジェクトを生成するとき画面の奥の方に生成して、徐々に手前に動いてくる。ゲームではタップする瞬間に音源と同期しないといけない。つまりゲームオブジェクト生成は音源よりも移動時間分早くないといけないのである。

後は自分の場合は音源はMuse ScoreからMP3に出力したものを使っていたので曲の初めに微妙なマージンなどがあったりしたのでその微妙な調整などもしておかないと遅延気味に感じる原因になった。

前者が本質的な調整なのでこちらを少し詳しく書いておくと、自分は次のようにした。AudioSourceを割り当てているObjectの適当なスクリプトに曲が始まっているかどうかを表すBool型の変数を適当に定義しておく。

ここで音符のゲームオブジェクトが移動してきてタップする点まで来たときに、このBool型の変数を読みに行く。その結果曲が始まっていないことが判定できたら、ここで初めてGetComponent<AudioSource>().Play()メソッドで音楽を再生されば良い。

ただしこの場合、音楽の始まりと最初のゲームオブジェクトが一致している場合を想定しているので、そうでない時は別の方法を考えないといけないかもしれない。

マージンの微妙な調整は適当にオフセットを定義して対応する。

オブジェクトの消滅

これもUnityの基本である。オブジェクトが動いてきてあるところよりも後ろに来たら、Destroyで消滅させるといったことを実装すればよい。

まとめ

今回やったのはMIDIファイルから曲の情報を読み取り、音楽と同期するようにゲームオブジェクト生成・移動させ、よくあるリズムゲームみたいなものを作った。とりあえずまだプロトタイプのプロトタイプくらいのものだが、引き続き開発し「音ゲー」に近づけていきたい。今回は長くなったのでソースを張り付けたりはしなかったが、今後プロジェクトが進行したときにまとめて公開するつもりである。