Qemelly(けめる)のプログラム備忘録

Unity / AtCoderについて書きます

【Unity】MIDIデータをUnityに取り込んでピアノロールを表示する - コード書くべ編

前回↓
qemelly.hatenablog.com


※私はエンジニアでないどころかコーディングは完全に独学です。しかも初心者です。間違いなくコードは汚いです。現在コードを綺麗にしたりちゃんと使えるように機能拡張したりしているのでその際には記事を書き直す予定です。
GitHubには公開しません。そもそももっといいのがネットに転がってる気がする。

こんな感じのを作りたい

(知りたい人向け)MIDIAninationAsset,MIDIの理解

まずは前回導入したMIDIAnimationAssetについて、少しだけ情報を整理します。

MIDIAnimationAssetを導入している状態でUnityに.midファイルを突っ込んでみてください。

このように変換されます。

・"005 LostVector"(曲名)となっている親オブジェクトはMidiFileAsset
・その子オブジェクトの、各トラックが記録されているオブジェクトがMidiAnimationAsset
・そこからMIDIのイベント情報1つ1つにアクセスするにはtemplate.events内に入っているMidiEvent

という感じです。

上の説明が分かりにくいことは承知なので、とにかくここでは下記コードのようにすればMIDIの1つ1つのイベント(音の高さや音量、鳴らしたか離したかなどの情報)にアクセスできる、とだけ理解しておいてください。

using System.Collections.Generic;
using UnityEngine;

namespace Logger
{
    public class MidiLogger : MonoBehaviour
    {
        [SerializeField] private MidiAnimationAsset _asset;

        private void Start()
        {
            var midiEventSet = _asset.template.events;
            foreach (MidiEvent midiEvent in midiEventSet)
                Debug.Log(midiEvent.ToString());
        }
    }
}

確認のため、各自以上のコードを適当なオブジェクトにアタッチし、Assetにトラックを入れた後に実行してみてください。

Consoleにこのように表示されれば成功です(数値は当然各々の用意している.midファイルによって異なります)。

ここで表示されている数値について。

左からそれぞれ「イベント発生時間’(Tick), チャンネルのノートのON/OFF, 音の高さ, ベロシティ」の情報が表示されます。変数名は「time, status, data1, data2」。

チャンネルのノートのON/OFFについてですが、チャンネルという概念はもはや過去の産物であり、DAWで普通に打ち込んで出力する分にはほとんど意識する必要がないので、基本はチャンネル1だけだと思って使えばいいです。チャンネル1であれば、16進数の90がON,80がOFFになります。これはそれぞれ10進数の128,144に対応します。

ピアノロールをCanvas上に表示する

では、ピアノロール出力をする神クラス(なんでもできてしまう良くないクラス)を作っていきます。コード書くのが上手な人はクラスを分割して神クラスを回避した方が良いと思われます。なお、今回はアニメーションにDoTween、非同期処理にUniTaskというパッケージを利用していますが、使わなくても普通に作れるとは思います。

ソースコード

MidiSpawner.cs
using Klak.Timeline.Midi;
using UnityEngine;
using DG.Tweening;
using Cysharp.Threading.Tasks;
using Utils.Math;

public class MidiSpawnerForBlog : MonoBehaviour
{
    [SerializeField] private GameObject _note;
    [SerializeField] private Transform _notesParent;

    [SerializeField] private AudioSource _audioSource;
    private Tweener _tweener;

    [SerializeField] private MidiAsset[] _midiNoteList;



    private float _notesParentLastPos = 0;

    private float _noteHeight;
    private float _noteHeightSpace = 3f;

    private (int x, int y) _rectTransformOffset = (260, 0);

    private float _scale = 100f;

    async private void Start()
    {
        OnInit();

        (int noteLowest, int noteHighest) = GetSupNotePitchInSong();

        foreach (MidiAsset visual in _midiNoteList)
        {
            PlayPianoRoll(visual, noteLowest, noteHighest);
        }

        //何故かMidiと音源がズレているので待つ
        await UniTask.DelayFrame(10);

        _audioSource.Play();
        MoveNotesParent();
    }


    private void OnInit()
    {
        _notesParent.position = new Vector2(0, 0);
        foreach (Transform child in _notesParent)
        {
            Destroy(child.gameObject);
        }

        _tweener.Kill();
        _audioSource.Stop();
    }


    private void PlayPianoRoll(MidiAsset midiNote, int noteLowest, int noteHighest)
    {
        if (!midiNote.enable) return;

        MidiEvent[] midiEventSet = midiNote.midiAnimationAsset.template.events;
        Gradient gradient = midiNote.gradient;

        // noteを全て画面上に表示する
        _noteHeight = 1080 / (noteHighest - noteLowest + 1) - _noteHeightSpace;

        Debug.Log(_noteHeight);


        for (int i = 0; i < midiEventSet.Length; i++)
        {
            var midiEvent = midiEventSet[i];

            if (i + 1 == midiEventSet.Length)
            {
                _notesParentLastPos = midiEvent.time;
            }

            float tempLength = SearchNote(midiEvent).Length;
            if (tempLength > 0)
            {
                var obj = Instantiate(_note, new(), Quaternion.identity, _notesParent);
                var objTransform = obj.GetComponent<Transform>();
                var objRenderer = obj.GetComponent<SpriteRenderer>();

                objTransform.position = new Vector2
                    (midiEvent.time - tempLength + _rectTransformOffset.x,
                    (midiEvent.data1 - noteLowest) * (_noteHeight + _noteHeightSpace));

                objRenderer.color = gradient.Evaluate(MathPlus.Map(midiEvent.data1, noteLowest, noteHighest, 0, 1));
                objRenderer.size = new Vector2(tempLength / _scale, _noteHeight / _scale);
            }
        }
    }

    private void MoveNotesParent()
    {
        _tweener = _notesParent
            .DOMove(new Vector2(-_notesParentLastPos, 0), _audioSource.clip.length)
            .SetEase(Ease.Linear);
    }



    private (int min, int max) GetSupNotePitchInSong()
    {
        (int min, int max) = (10000, 0);
        foreach (var mv in _midiNoteList)
        {
            (int cmin, int cmax) = GetSupNotePitch(mv.midiAnimationAsset.template.events);
            min = Mathf.Min(min, cmin);
            max = Mathf.Max(max, cmax);
        }

        return (min, max);
    }
    /// <summary>
    /// 最も低いノートと最も高いノートを返します。
    /// </summary>
    /// <param name="debug">consoleに表示したいとき用</param>
    private (int min, int max) GetSupNotePitch(MidiEvent[] midiEventSet, bool debug = false)
    {
        int noteMin = int.MaxValue;
        int noteMax = 0;

        for (int i = 0; i < midiEventSet.Length; i++)
        {
            noteMin = Mathf.Min(noteMin, midiEventSet[i].data1);
            noteMax = Mathf.Max(noteMax, midiEventSet[i].data1);
        }

        if (debug) Debug.Log($"ノートの高さ... 最小 : {noteMin}, 最大 : {noteMax}");

        return (noteMin, noteMax);
    }

    private int[] _notePitchBox = new int[200];

    /// <summary>
    /// event二つの差分を取り、noteの長さをintで返します。
    /// </summary>
    private (int Length, bool IsPushed) SearchNote(MidiEvent midiEvent)
    {
        int notePitch = midiEvent.status;
        //note is released
        if (notePitch == 128)
        {
            var temp = _notePitchBox[midiEvent.data1];
            _notePitchBox[midiEvent.data1] = 0;

            return ((int)midiEvent.time - temp, false);
        }
        //note is pushed
        else if (notePitch == 144)
        {
            //ノートの高さのindexにある箱に時間を記入
            _notePitchBox[midiEvent.data1] = (int)midiEvent.time;
            return (0, true);
        }
        else
        {
            Debug.Log($"{midiEvent.time}tickにてnoteがただしく認識されませんでした。");
            return (0, false);
        }

    }
}
MidiAsset.cs
using Klak.Timeline.Midi;
using UnityEngine;

[CreateAssetMenu(fileName = "MidiAsset", menuName = "ScriptableObjects/MidiAssetScriptableObject", order = 1)]
public class MidiAsset : ScriptableObject
{
    public MidiAnimationAsset midiAnimationAsset;
    public Gradient gradient;
}
MathPlus.cs
using Klak.Timeline.Midi;

namespace Utils.Math
{
    public static class MathPlus
    {
        /// <summary>
        /// 指定の範囲1内のvalueを範囲2内にスケールチェンジして返します。
        /// </summary>
        public static float Map(float value, float start1, float stop1, float start2, float stop2)
            => start2 + (stop2 - start2) * ((value - start1) / (stop1 - start1));

        /// <summary>
        /// midievent上のtimeを実測のtimeに変換します。
        /// </summary>
        /// <param name="midiTime">MidiEvent.time</param>
        /// <param name="tempo">MidiAnimationAsset.template.tempo</param>
        public static float GetSecondFromMidiEvent(MidiEvent midiEvent, float tempo) 
            => (midiEvent.time * 120f) / (1920 * tempo);
    }
}

使い方

まずはMidiAssetを生成します。「Project」の適当なところで右クリックして「Create→ScriptableObjects→MidiAssetScriptableObject」をクリック。

すると下図のようなプロパティを持ったデータが出てくるのでここのMidi Animation Assetに用意したTrackを代入。Gradientに表示させたいノートの色を入れます。


次にNoteを用意します。Spriteを生成して、適当な画像を設定してください。見えないけどこんな感じの↓。色は後からつけるので白が望ましい。

Main Cameraの設定。Game画面はFull HDです。


あとは空オブジェクトを2つ作ってテキトーにそれぞれNotes,MidiSpawnerって名前にでもしてMidiSpawnerにスクリプトをアタッチして各項目を以下のように設定すれば動くはず(音声も流したい場合はAudio Sourceも忘れずに作って音楽を入れてください)...


想定完成図

コードの説明

MidiSpawner.cs

神クラス。今後プログラミングスキルが向上したらもっとすっきりさせたい...。

//何故かMidiと音源がズレているので待つ
        await UniTask.DelayFrame(10);

こことかもう最悪ですね。いつか解決します。

Midiの読み込みからピアノロールの生成、アニメーションまで何でもやってしまいます。

MidiEvent[] midiEventSet = midiNote.midiAnimationAsset.template.events;

でイベントを取ってきて、あとは色々情報を変換していく作業です。

MidiAsset.cs

このブログを書くまではここをただのクラスにしていたんですが、Visual Studio 「: Scriptable Object」を書きたがっていたのを見て、確かにScriptable Object使えばいいじゃん!と気づいて継承しました。Visual Studio頭いい。

Scriptable Objectは簡単に言うとデータを格納できるPrefabのようなものです。ゲームだと敵の情報の管理だったりカードの情報だったりと様々な情報を管理するのに利用することが出来る優れものです。機会があったら記事にしたいと思います。

MathPlus.cs

その他計算。