toioをクラシック音楽のMIDIデータから音楽を鳴らしながら躍らせる

初めに

年末ごろに toioを買っていろいろ遊んでいたので、その一つの成果に対する内容になります!

以下のように Unity Editorでシミュレーションしながら開発をした クラシック音楽に合わせて踊るtoioを実際に動かしてみました

Unity Editorでのシミュレーションの動画は以下です

youtu.be

この記事は xRギルド Advent Calendar 2024 12日目の記事です。

toioとは

toioは公式サイトには以下のような説明があります。

いろいろなものを取り付けて自由にあそべるtoio。はじめの一歩はガイドにそって。少しずつ変えながら、想像を形に。つくる楽しさ、つくったものであそぶ喜び、そこで出会う偶然の発見がひらめきにつながっていく。手を動かして夢中になるうちに、小さなひらめきが積み重なって、創意工夫が自然に生まれる。そんな体験がtoioには詰まっています。

(https://toio.io/ より)

しかし、本格的に本格ロボットプログラミングができるようにコア キューブ技術仕様の公開やjavascriptpython・unityで開発を行うことができます

(https://toio.io/programming/advanced/ より)

以下の コアキューブ技術仕様(v2.4)では、以下の要素部分を制御することができます

  • 読み取りセンサー
  • モーション検出
  • 姿勢角検出
  • 磁気センサー
  • ボタン
  • バッテリー
  • モーター
  • ランプ
  • サウンド
  • シリアライズ情報

開発環境

midiファイルからtoio-jsonに変換する

toio-jsonとは

以下のようなtoioのSDKで扱いやすくしたフォーマットと定義します

[
  {
    "track_name": "ALBENIZ: Aragon Op 47/6",
    "priority": 1,
    "notes": [
      {
        "note_number": 77,
        "start_time_ms": 0,
        "duration_units": 26
      },
      {

      },
  },
    {
    "track_name": "apurdam@pcug.org.au",
    "priority": 2,
    "notes": [
        {
        "note_number": 53,
        "start_time_ms": 0,
        "duration_units": 58
        },
    ]
    }
]

まずはtoioで鳴らすサウンドのデータを作る必要があります。そこで、今回はライセンス上使いやすい クラシック音楽を使用していきます。

以下にクラシック音楽midiファイルのデータがあるので、こちらを使ってjsonに変換していきます

huggingface.co

環境構築

変換するためのpythonを使って変換処理をおこなっていきます。

今回はuvを使ってpython3.11を作っていきます。

uv venv -p 3.11
.venv\Scripts\activate

次に必要なライブラリを入れていきます

uv pip install mido==1.3.3 packaging==24.2 python-rtmidi==1.5.8

midiファイルからjsonに変換

次にmidiファイルからjson形式に変換していきます。以下のコードを使って変換を行います。

import mido
import json
import os
from multiprocessing import Pool, cpu_count
from functools import partial

def midi_to_toio_notes(midi_file_path):
    # MIDIファイルの読み込み
    try:
        midi_file = mido.MidiFile(midi_file_path)
        print(f"Loaded MIDI file: {midi_file_path}")
    except Exception as e:
        print(f"Failed to load MIDI file: {midi_file_path}, Error: {e}")
        return

    # テンポの取得(デフォルトのテンポを設定)
    tempo = 500000  # デフォルトのテンポ(500,000マイクロ秒/拍 = 120BPM)
    for track in midi_file.tracks:
        for msg in track:
            if msg.type == 'set_tempo':
                tempo = msg.tempo
                break
        else:
            continue
        break

    ticks_per_beat = midi_file.ticks_per_beat

    # 時間の変換用の係数
    tick_time = tempo / ticks_per_beat  # 1tickあたりの時間(マイクロ秒)
    # print(f"Tempo: {tempo} microseconds per beat")
    # print(f"Ticks per beat: {ticks_per_beat}")
    # print(f"Tick time: {tick_time} microseconds per tick")

    # 全トラックのデータを格納するリスト
    tracks_data = []
    priority_counter = 1  # 優先度のカウンターを初期化

    # トラックごとに処理
    for i, track in enumerate(midi_file.tracks):
        current_time = 0  # 累積時間(ticks)
        note_on_events = {}

        # print(f"Processing Track {i}: {track.name}")

        track_notes = []

        # トラック名を取得(なければ番号)
        track_name = track.name if track.name else f"Track {i}"

        for msg in track:
            current_time += msg.time  # 時間を累積

            if msg.type == 'set_tempo':
                # 曲中でテンポが変更された場合に対応
                tempo = msg.tempo
                tick_time = tempo / ticks_per_beat  # 1tickあたりの時間(マイクロ秒)
                # print(f"Tempo change detected at {current_time} ticks: {tempo} microseconds per beat")
                continue

            if msg.type == 'note_on' and msg.velocity > 0:
                # Note On イベント
                note_on_events.setdefault(msg.note, []).append(current_time)
            elif (msg.type == 'note_off') or (msg.type == 'note_on' and msg.velocity == 0):
                # Note Off イベント
                if msg.note in note_on_events and note_on_events[msg.note]:
                    start_time = note_on_events[msg.note].pop(0)
                    duration = current_time - start_time

                    # 時間をミリ秒に変換
                    start_time_ms = (start_time * tick_time) / 1000  # ミリ秒
                    duration_ms = (duration * tick_time) / 1000  # ミリ秒

                    note_number = msg.note

                    # toioの音程範囲(45~81)に合わせて音程を調整
                    original_note_number = note_number  # デバッグ用
                    while note_number < 45:
                        note_number += 12
                    while note_number > 81:
                        note_number -= 12

                    # 再生時間を10ms単位に変換(1~255の範囲)
                    play_time_units = int(duration_ms / 10)
                    if play_time_units < 1:
                        play_time_units = 1
                    elif play_time_units > 255:
                        play_time_units = 255

                    # 音符情報を保存
                    note_info = {
                        'note_number': note_number,
                        'start_time_ms': int(start_time_ms),
                        'duration_units': play_time_units
                    }
                    track_notes.append(note_info)

                    # デバッグ用の出力をコメントアウトまたは削除可能
                    # print(f"{track_name}, Note {original_note_number} ({start_time_ms:.2f} ms): Duration {duration_ms:.2f} ms, Adjusted Note {note_number}, Play Time Units {play_time_units}")

        if track_notes:
            # トラック情報を保存
            track_data = {
                'track_name': track_name,
                'priority': priority_counter,
                'notes': track_notes
            }
            tracks_data.append(track_data)
            priority_counter += 1  # 音符情報があるトラックに対してのみ優先度を増加

    if not tracks_data:
        print(f"No note data found in MIDI file: {midi_file_path}")
        return

    # 各トラックの音符を開始時間でソート
    for track_data in tracks_data:
        track_data['notes'].sort(key=lambda x: x['start_time_ms'])

    # MIDIファイル名からJSONファイル名を生成
    midi_filename = os.path.basename(midi_file_path)
    midi_name, _ = os.path.splitext(midi_filename)
    output_json_filename = f'{midi_name}_processed.json'
    output_json_path = os.path.join(os.path.dirname(midi_file_path), output_json_filename)

    # データをJSONファイルに保存
    try:
        with open(output_json_path, 'w') as f:
            json.dump(tracks_data, f, indent=2)
            print(f"Notes have been saved to {output_json_path}")
    except Exception as e:
        print(f"Failed to save JSON file: {output_json_path}, Error: {e}")

def collect_midi_files(root_dir):
    midi_files = []
    for dirpath, dirnames, filenames in os.walk(root_dir):
        for filename in filenames:
            if filename.lower().endswith(('.mid', '.midi')):
                midi_file_path = os.path.join(dirpath, filename)
                midi_files.append(midi_file_path)
    return midi_files

def process_all_midis(root_dir):
    midi_files = collect_midi_files(root_dir)
    total_files = len(midi_files)
    print(f"Total MIDI files to process: {total_files}")

    cpu_cores = cpu_count()
    print(f"Using {cpu_cores} CPU cores for parallel processing")

    with Pool(processes=cpu_cores) as pool:
        pool.map(midi_to_toio_notes, midi_files)

if __name__ == '__main__':
    import sys

    # dataディレクトリのパスを指定
    data_dir = 'data'  # スクリプトの実行ディレクトリに対する相対パス

    # コマンドライン引数でデータディレクトリを指定可能
    if len(sys.argv) > 1:
        data_dir = sys.argv[1]

    if not os.path.exists(data_dir):
        print(f"The specified directory does not exist: {data_dir}")
        sys.exit(1)

    process_all_midis(data_dir)

このコードのように実行します

python midi_to_toio.py midi_file_path

先ほどのデータセットからjsonに変換したものは以下にて公開しています。自分で変換するのが大変という方はこちらからダウンロードしてお使いください。

huggingface.co

また変換するための処理は以下のリポジトリにまとめています。

github.com

Unityでmidi-jsonからtoioを動かす

セットアップ

まずは Unityでtoioが動く環境を作成します。Unityのインストールは終わっているものとします。

toio SDK for Unity v1.6.0から Unity向けのSDKをダウンロードして、importを行います。

untiyからキューブに接続する

toio sdk for unityでは以下の流れでunityからキューブに対して、接続をします。

  1. CubeScanner.NearestScan()で近くのキューブを探す
  2. CubeConnecter().Connect()で接続

toioで特定の音を再生する

toioでは Midi note numberとnote nameの対応表があります。

toio.github.io

これに従うと 特定の周波数の音が出すことができます。

C#で特定の音を再生する場合は、以下のように実装します。

Cube.SoundOperation soundOp = new Cube.SoundOperation(duration_ms, volume, note.note_number);
cube.PlaySound(1, new Cube.SoundOperation[] { soundOp });

midi-jsonをロードする

cube側に note_numberduration_ms の情報を渡すため、先ほどjsonからデータをロードする処理を作ります。

まずはデータ用のクラスを定義します

    [Serializable]
    public class NoteData
    {
        public byte note_number;
        public int start_time_ms;
        public int duration_units;
    }

    [Serializable]
    public class TrackData
    {
        public string track_name;
        public int priority;
        public List<NoteData> notes;
    }

次にローカルにあるjsonデートをロードして、上記のクラスに格納していく処理を作ります。

今回はmidiファイルをロードしてクラスに入れるクラスを Song とします。

public class Song
    {
        public List<TrackData> Tracks { get; private set; }

        public Song()
        {
            Tracks = new List<TrackData>();
        }

        // JSONファイルからデータを読み込むメソッドを追加
        public async UniTask LoadFromJsonAsync(string jsonFilePath)
        {
            Tracks.Clear();

            // ファイルが存在するかチェック
            if (!File.Exists(jsonFilePath))
            {
                Debug.LogError($"JSON file not found: {jsonFilePath}");
                return;
            }

            try
            {
                // ファイルからJSON文字列を非同期的に読み込む
                string jsonText = await ReadFileAsync(jsonFilePath);
                

                // JSONをパースしてTrackDataのリストを取得
                Tracks = JsonConvert.DeserializeObject<List<TrackData>>(jsonText);

                if (Tracks == null || Tracks.Count == 0)
                {
                    Debug.LogError("No track data found in JSON.");
                    return;
                }

                // トラックを優先度でソート(昇順)
                Tracks.Sort((a, b) => a.priority.CompareTo(b.priority));
                Debug.Log($"Loaded {Tracks.Count} tracks from JSON.");
            }
            catch (Exception e)
            {
                Debug.LogError($"Failed to load or parse JSON file: {e.Message}");
            }
        }

        // ファイルを非同期で読み込むヘルパーメソッド
        private async UniTask<string> ReadFileAsync(string filePath)
        {
            using (var reader = new StreamReader(filePath))
            {
                return await reader.ReadToEndAsync();
            }
        }
    }

これによりローカルにあるmidi-jsonファイルからmidiデータをロードすることができるようになりました。

midi-jsonからtoioでクラシック音楽を鳴らす

ここまででローカルのmidiファイルから作成したjsonデータをロードして、toioで音を鳴らす準備ができました。最後に再生時間ごとにどのnoteを鳴らす計算して jsonのデータで一つのリスト文を鳴らすようにします。

以下は 先ほど作成した NoteDataのリストデータの TrackDataを用いて音を再生する処理になります。

private async UniTask PlayTrackOnCubeAsync(Cube cube, TrackData track)
        {
            if (cube == null || track == null || track.notes == null || track.notes.Count == 0)
            {
                return;
            }

            Debug.Log($"Starting playback on cube {cube.id} for track '{track.track_name}'");

            float startTime = Time.time;

            foreach (var note in track.notes)
            {
                // 現在時刻から経過時間を計算
                float elapsedTime = (Time.time - startTime) * 1000f; // ミリ秒に変換
                float waitTime = (note.start_time_ms - elapsedTime) / 1000f; // 秒に変換

                if (waitTime > 0)
                {
                    // 次の音符まで待機
                    await UniTask.Delay(TimeSpan.FromSeconds(waitTime));
                }

                // 音符を再生
                ushort duration_ms = (ushort)(note.duration_units * 10); // duration_unitsをミリ秒に変換
                byte volume = 15; // 音量を設定

                Cube.SoundOperation soundOp = new Cube.SoundOperation(duration_ms, volume, note.note_number);
                cube.PlaySound(1, new Cube.SoundOperation[] { soundOp });
            }

            Debug.Log($"Finished playback on cube {cube.id} for track '{track.track_name}'");
        }

cubeに移動の命令を送る

cubeに対して移動の命令は以下で実行することができます。

// キューブを動かす(命令の優先度を強く設定)
cube.Move(action.leftSpeed, action.rightSpeed, action.durationMs,Cube.ORDER_TYPE.Strong);

公式のドキュメントは以下になります。

toio.github.io

midi情報から動きを決める

noteの情報からcubeの右・左のモーターの速度および移動時間を計算します。このときにcubeが移動できる範囲(シミュレーション上)が決まっているので、落ちないように調節をしました。

private List<MovementAction> GenerateMovementPlan(TrackData track, bool mirror)
    {
        List<MovementAction> movementPlan = new List<MovementAction>();

        foreach (var note in track.notes)
        {
            float startTime = note.start_time_ms / 1000f; // 開始時間(秒)
            int durationMs = note.duration_units * 10; // 持続時間(ミリ秒)

            // ノート番号をモーター速度にマッピング
            (int leftSpeed, int rightSpeed) = MapNoteNumberToSpeeds(note.note_number, mirror);

            // MovementActionの作成
            MovementAction action = new MovementAction
            {
                startTime = startTime,
                durationMs = durationMs,
                leftSpeed = leftSpeed,
                rightSpeed = rightSpeed
            };

            movementPlan.Add(action);
        }

        return movementPlan;
    }

    // ノート番号を左右のモーター速度にマッピングするメソッド
    private (int leftSpeed, int rightSpeed) MapNoteNumberToSpeeds(int noteNumber, bool mirror)
    {
        // ノート番号を0~1に正規化
        float normalized = (noteNumber - _minNote) / (float)(_maxNote - _minNote);

        // スピードを決定(速度の範囲を60~100に設定)
        int baseSpeed = (int)(normalized * 40) + 60; // 60~100に変換

        int leftSpeed, rightSpeed;

        if (mirror)
        {
            // キューブ2(ミラーリング)では、左に曲がる
            leftSpeed = baseSpeed - 20; // スピードを減少
            rightSpeed = baseSpeed;
        }
        else
        {
            // キューブ1では、右に曲がる
            leftSpeed = baseSpeed;
            rightSpeed = baseSpeed - 20; // スピードを減少
        }

        // スピードの範囲を調整(-100から100)
        leftSpeed = Mathf.Clamp(leftSpeed, -100, 100);
        rightSpeed = Mathf.Clamp(rightSpeed, -100, 100);

        return (leftSpeed, rightSpeed);
    }

これで クラシック音楽midi情報からtoioのcubeを使って音楽を再生しつつ音楽に合わせて動かすことができました。