たつぷりの調査報告書

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

Unityで電子ドラムを音ゲー入力装置にする

こんにちは、たつぷりです。今回も、UnityでMIDIを用いたリズムゲームを作るシリーズです。

前回までは、MIDIの楽曲データをからノートオブジェクトを生成して曲に合わせて再生することをやってきました。今回はこのゲームに入力システムを作成します。しかし、普通のスマホリズムゲームみたいに指でタップするだけではあまり面白くないので、電子ドラムをiOS向けリズムゲームの入力する方法を考えます。

今回の範囲でできたのは以下のような感じです。

www.youtube.com

ちなみに私のドラム経験は0なので、スティックの握り方さえあってるか分かりません...

関連記事は以下の記事です

tatsupuri.hatenablog.com

目標

以前Unityで作成したMIDIデータからノートを生成して音ゲー風に再生することを行った。 今回目標とするのは、本物の電子ドラムをゲームの入力装置として使うことである。

より具体的には以下の状況を想定している。

  • iPhone(またはiPad)のライトニングポートと電子ドラムのMIDI出力ポートを接続する

  • iPhineではリズムゲームが始まる

  • 電子ドラムを叩くと、iPhoneのゲームに対応する入力を送信する

やること

以上の目標を実現するために必要なことを考える。 まず電子ドラムからのMIDI信号をiPhoneで処理するには、CoreMIDIフレームワークを用いる。 ここでCoreMIDIはiOSのネイティブプログラムであるので、Unityから直接扱うことができない。UnityからiOSネイティブプログラムを呼び出すにはUnity側でプラグインを用意しておく必要がある。

つまり今回行うことは、

  • UnityからCoreMIDIフレームワークを呼び出すためのプラグインを作成する

  • XcodeでCoreMIDIを用いて、ドラムからのMIDI信号を取得し、信号に応じてUnityにメッセージを送る機能を実装する

  • Unity側でドラム入力に対応した信号を受け取りそれをゲームに反映させる

  • iOS向けにビルドする

である。今回iOS側からUnityへの信号送信はUnitySendMessage関数を用いる。これはObjective-Cの関数である。また プラグインObjective-C++で書かれる。そのため今回はCoreMIDI周りのプログラムはObjective-Cで書くことにする(Swiftは今回使わない)。

Objective-C版のCoreMIDI公式レファレンス developer.apple.com

なお最後の項目、「iOS向けにビルドする」に関しては、個人的につまづくポイントがいくつかあったので、それらはまた改めて別のノートにまとめることにする。

電子ドラム

今回用いる電子ドラムはMEDELI DD401Jである。見た目は以下の図。

f:id:Tatsupuri:20201129190532j:plain:w500

出力は以下の図のようになっている。今回は、MIDI出力のUSBポートと、iPadを接続します。

f:id:Tatsupuri:20201129190509j:plainf:id:Tatsupuri:20201129190504j:plain

UnityでiOSプラグインを作成する

UnityでiOSのネイティブ機能を使うためには、プラグインを作成する必要がある。公式のレファレンスはここ

まず、iOSプラグインをおく場所はAssets > Plugin > iOSである。ここにObjective-C++ファイルを作成する。拡張子は.mm である。なおUnityから直接作成することはできないので適当なエディターを用いて作成する。

f:id:Tatsupuri:20201129165404p:plain

CoreMIDIを使った処理

このノートの範囲では大まかな流れと重要な処理に対してのみ記述する。その他の細かいことは、また別のノートにまとめる。

なお、このセクションの内容を書くにあたって公式レファレンスはもとより、以下の資料も参考になった。

メインの関数は、以下のGetMIDIである。これは、Bool型の返り値を返す。まずはこのコードの構造をまとめておく。 基本的な流れは、以下の通りである。

GetMIDIは実際のプロジェクトはゲームのオープニング画面でその処理が行われる。この関数ではMIDIバイスと通信するために必要な以下の一連の流れを実行する。

これらの準備が整い、電子ドラムがコントローラーとして使えるようになったらGetMIDItrueを返し、オープニング画面のスタートボタンが押せるようにしている。「Start」を押すとシーンが遷移し、ゲームが始まるという流れである。

f:id:Tatsupuri:20201129165413j:plain:w300f:id:Tatsupuri:20201129165417p:plain:w300

具体的にGetMIDIをUnityから呼び出した時、大まかには以下の流れで処理が行われる。

  1. MIDIGetNumberOfSourcesiOSバイスに接続されているMIDIバイスの数を取得する。ここでは複数のデバイスが接続されることを想定していないので、結果が1の時のみ先に進み、それ以外ではfalseを返す。

  2. MIDIGetSource(0)で上で検出したデバイスMIDIエンドポイントを取得する。MIDIObjectGetStringPropertyで正しくエンドポイントの情報が取得できたら先に進む。

  3. MIDIClientCreateWithBlockMIDIクライアントを作成する。正しく作成できたら先に進む。

  4. MIDIInputPortCreateWithBlockMIDIポートを作成する。コールバックとして、onMIDIMessageReceivedというブロックを指定している。このブロックに受け取ったMIDIデータに対応じた処理を書く。つまりゲームの制御に関する処理はここに書かれる。ここも正しくポートを作成できたら先に進む。

  5. MIDIPortConnectSource で上で取得したエンドポイントと作成したMIDIポートを接続する。正しく接続できたらtrue を返す。

bool GetMIDI()
{
    if (MIDIGetNumberOfSources() == 1)
    {

        MIDIEndpointRef source = MIDIGetSource(0);

        OSStatus err;
        CFStringRef strEndPointRef = NULL;

        err = MIDIObjectGetStringProperty(source, kMIDIPropertyName, &strEndPointRef);

        if (err == noErr)
        {

            MIDIClientRef client = MIDIClientRef();

            err = MIDIClientCreateWithBlock(strEndPointRef, &client,onMIDIStatusChanged);

            if(err == noErr)
            {
                NSLog(@"MIDIClient created");

                CFStringRef portName = (CFStringRef)@"inputPort";
                MIDIPortRef port = MIDIPortRef();

                err = MIDIInputPortCreateWithBlock(client, portName, &port, onMIDIMessageReceived);

                if(err == noErr)
                {
                        NSLog(@"MIDIInputPort created");
                        err = MIDIPortConnectSource(port, source, nil);

                        if(err == noErr)
                        {
                            NSLog(@"MIDIEndpoint connected to InputPort");
                            return true;
                        }
                }

            }

        }
    }
    return false;
}

onMIDIMessageRecieved ブロックに受け取ったMIDI信号に対しての処理を書く。

MIDI信号はMIDIパケットリストの形で渡されるので、まずはここから先頭のMIDIパケットを取得する。基本的に各パケットに対してのループを回して、パケット毎に処理を行う。

MIDIパケットは(ステータスバイト,ノート,ベロシティ) の情報で構成されているので、パケットをこの状態に分解する。それぞれの情報はdata[0] ,data[1] ,data[2] に格納されている。ここでやりたいのは以下の処理である。

受け取ったMIDIパケットのステータスバイトがドラムの発音情報、すなわち0x99である時(かつベロシティが0でない時)以下の処理をする

  • MIDIパケットのノート情報を文字列に変換する

  • UnitySendMessage 関数でUnityにノート情報を送信する

受け取ったMIDIパケットのステータスバイトがドラムの発音情報、すなわち0x99である時(かつベロシティが0でない時)、”ドラムを叩いた”ことになるのでUnity側にメッセージを送る。

この時、UnityへはUnitySendMessage 関数で情報を送ることができる。UnitySendMessage は、あるゲームオブジェクトがもつメソッドに対して文字列のみを送ることができる。かなり制限のある関数であるが、今回の目的の上ではこれで十分である。ここではbarオブジェクトのNoteJudgeメソッドにMIDIパケットから取得したノート情報を文字列として送る。この場合は、”ドラムのどこ(スネアなど...)を叩いたのか”という情報が送られることになる。

MIDIPacketNext で次のMIDIパケットの処理に移る。

MIDIReadBlock onMIDIMessageReceived = ^(const MIDIPacketList *pktlist, void *srcConnRefCon)
{
    //MIDIパケットリストの先頭のMIDIPacketのポインタを取得
    MIDIPacket *packet = (MIDIPacket *)&(pktlist->packet[0]);
    //パケットリストからパケットの数を取得
    UInt32 packetCount = pktlist->numPackets;

    for (NSInteger i = 0; i < packetCount; i++)
    {   
        Byte status = packet->data[0];
        Byte note = packet->data[1];

        NSString *str = [NSString stringWithFormat:@"%d", note];

        if((status == 0x99) && (packet->data[2] != 0))
        {
            UnitySendMessage("bar", "NoteJudge", [str UTF8String]);
        }

        //次のパケットへ進む
        packet = MIDIPacketNext(packet);
    }
};

Unity側の処理

GetMIDIをUnityから使う

GetMIDIプラグインで定義された関数であるので、これをUnityから呼び出すには、以下のようにしておく必要がある。

下の名前空間を導入する。

using System.Runtime.InteropServices;

メンバー変数の定義と一緒に下を宣言する。

[DllImport("__Internal")] private static extern bool GetMIDI();

これで、Unityからプラグインで定義した GetMIDI関数が使えるようになる。

UnitySendMessageをUnityで受け取る

今回は、UnitySendMessage("bar", "NoteJudge", [str UTF8String]); でノート情報を送信していた。これを受け取るのは、bar オブジェクトに割り当てられたNoteJudge メソッドである。今回はこのメソッドを以下のようにJudgeクラスに定義した。

NoteJudgestr型の引数を受け取るように定義しておく。こうすることで、UnitySendMessageが呼ばれるとこのNoteJudge関数がノート情報(の文字列)を引数にして実行される。 具体的にこれが呼ばれると、今叩くべきノートオブジェクトを取得して、それに対応したところが実際に電子ドラムで叩かれたかどうかを判定している。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class Judge : MonoBehaviour
{
    public Text text;

    private void NoteJudge(string str)
    {
        text.text = str;

        foreach (GameObject justNote in GameObject.FindGameObjectsWithTag("now"))
        {      
            if(justNote.GetComponent<Note>().lineNumber == Line(str))//MIDIからのトーン情報から参照
            {
                justNote.GetComponent<Note>().Success();
            }
        }
    }

    private int Line(string tone)
    {
        if(tone == "36")//バスドラム
        {
            return 4;
        }
        else if(tone == "38")//スネア
        {
            return 3;
        }
        else if(tone == "42")//クローズドハイハット
        {
            return 2;
        }

        return 0;
    }
}