見出し画像

clusterでMIDI信号のビジュアライズ的なのをやりたいときに読む記事

clusterでMIDIのビジュアライズ的なのをやりたいときに読む記事です。

ノリとしては「clusterのワールドで画面全体をブルブル振動させたいときに読む記事」と同様、具体用途にフォーカスした記事です。

※「読んでも分からんけど作りたいモノならあります!」という場合、記事の末尾まで飛ぶのを検討してください。

やること

こういうのを作ります。

つまり、MIDI入力デバイスとして認識できるような電子キーボード等のデバイスをPCに接続して演奏したときに、clusterのワールド上に音階ベースで演出を表示します。


必要なもの

必要なものとして、本記事ではMIDIでノート情報が入力できるデバイス、およびWindows環境が必須です。

これは記事中で作ってる「MIDI入力をOSCメッセージに変換するプログラム」がWindows専用だからです。clusterのワールドそのものはOSを問わず動作するので、ワールドの部分だけ再利用するような使い方も行けると思います。

試してみるには

※ワールドを自作する前提の場合、このステップは省いても構いません。

下記のGitHubページで、手順に沿ってexeのインストールとセットアップを行い、手持ちデバイスでMIDI入力が検出できることを確認します。

https://github.com/malaybaku/MidiNoteToOsc

その後、Windows版のclusterで「ばくすたー実験室」に入って、

「MIDIビジュアライザー」のカンバンの脇にあるキューブにインタラクトします。

clusterでOSCを受信する設定がオンになっていない場合、オンにします。また、「OSC入力の設定」で、ポート番号を9000にしておきます。

この状態でMIDIキーボードを演奏して演出が発生すれば成功です!


前提知識

以下は知っている前提で話を進めます。

  • OSC(OpenSoundControl): 「何か通信フォーマットらしい」と知っていれば十分です。本記事ではPC内での通信のみに使うので、ネットワーク関連の知識も不要です。

  • Cluster Creator Kit: ワールドのアップロード手順とかは知ってる前提になります。

  • ClusterScript: 「スクリプトで色々できる」ということに加えて、ItemScriptPlayerScriptという2種類のスクリプトがあることも前提になります。ただし、シーン丸ごとのサンプルも置いているので、詳しくなくても再現できます(多分)。

  • Unityの基礎知識

では作っていきます。

MIDI入力をOSCメッセージに変換する

まず、MIDI入力をOSCメッセージに変換してclusterから読み取れるようにします。方法はいくつか考えられますが、今回は要件が大したことないので自前でプログラムを書いてしまいます…で、書いたものがコチラです。

これはシンプルなコンソールアプリケーション(CLI)になっています。使い方や、具体的な実装(≒MIDIやOSCの処理に使ってるライブラリ)についてはGitHub側を参照してください。

cluster側でコレを活用するうえでのポイントはOSCメッセージの送信内容で、これは以下のようになっています。

メッセージの送り方:

  • デフォルトでは、exeを実行した端末自身のポート 9000 に向けてOSCメッセージを送信している

  • OSCメッセージのアドレスのデフォルトは /baxter/midi で、サステインペダルのメッセージのアドレスは /baxter/midi/sustain

メッセージの内訳:

  • ノートの情報: int, bool の2つ

    • int: 0~127の範囲のノート番号

    • bool: trueなら鍵盤を押した、falseなら鍵盤を離したメッセージに対応

  • サステインペダルの情報: bool が1つだけ

    • ペダルを押すとtrue、離すとfalse


clusterのワールド

OSCの送信準備ができたらワールドを作っていきます。スクリプトやprefabのセットアップも紹介しますが、イチから再現するのは結構めんどくさいセットアップもあるため、ワールド側のunitypackageを用意してます。ご活用下さい。

※ダウンロードの直リンクを踏みたくない人向けの補足: unitypackageは一つ前のセクションで出てきたGitHubのReleasesページに置いてます。


Cluster Script (Player Script)

Player ScriptではOSCのメッセージを受信しながら、アイテムに対してMIDIノート全体のon/offに関する情報を一定間隔で送信します。

/// <reference path="./index.d.ts"/>

// trueにした場合、サステインペダルを考慮した値を送信する。
// これをtrueにする場合、打鍵したときのビジュアル演出が徐々に減衰するようにカスタムしたほうが視認性がよい
const SendSustainPedalBasedValue = true;

const SendInterval = 0.1;
let sendTime = 0;

let sustainPedalIsOn = false;

// notesOn: 実際に打鍵しているかどうかを示す値
// notesOnSustain: notesOnと似ているが、サステインペダルがオンの間はnotesOnがオフになってもtrueのまま残る
// notesActivated: notesOnに切り替わったとき1回だけオンになり、sendするとオフになる
let notesOn = [];
let notesOnSustain = [];
let notesActivated = [];
for (let i = 0; i < 128; i++) {
  notesOn.push(false);
  notesOnSustain.push(false);
  notesActivated.push(false);
}

_.oscHandle.onReceive((messages) => {
  for (let msg of messages) {
    if (msg.address === "/baxter/midi") {
      handleNoteMessage(msg);
    } else if (msg.address === "/baxter/midi/sustain") {
      handleSustainPedalMessage(msg);
    } else {
      _.log(`unexpected address, ${msg.address}`)
    }
  }
});

/** 
 * @param {OscMessage} msg
 */
const handleNoteMessage = (msg) => { 
  if (msg.values.length < 2) return;

  const note = msg.values[0].getInt();
  const isOn = msg.values[1].getBool();

  if (note == null || isOn == null) return;

  notesOn[note] = isOn;
  if (isOn) {
    notesOnSustain[note] = true;
    notesActivated[note] = true;
  } else if (!isOn && !sustainPedalIsOn) {
    notesOnSustain[note] = false;
  }
};

/** 
 * @param {OscMessage} msg
 */
const handleSustainPedalMessage = (msg) => {
  if (msg.values.length < 1) return;

  const isOn = msg.values[0].getBool();
  if (isOn == null) return;

  sustainPedalIsOn = isOn;

  // 指がすでに離れていてサステインペダルで鳴っていたキーをストップ
  if (!isOn) {
    for (let i = 0; i < 128; i++) {
      if (!notesOn[i]) {
        notesOnSustain[i] = false;
      }
    }
  }
};

_.onFrame((deltaTime) => { 
  sendTime += deltaTime;
  if (sendTime <= SendInterval) return;

  sendTime -= SendInterval;

  const noteValues = [
    createIsNoteOnValues(0),
    createIsNoteOnValues(32),
    createIsNoteOnValues(64),
    createIsNoteOnValues(96),
    createIsNoteOnThisSendValues(0),
    createIsNoteOnThisSendValues(32),
    createIsNoteOnThisSendValues(64),
    createIsNoteOnThisSendValues(96),
  ];

  for (let i = 0; i < 128; i++) {
    notesActivated[i] = 0;
  }
  
  _.sendTo(_.sourceItemId, "SetNote", noteValues);
});

/**
 * 打鍵した直後のsendでだけtrueが入ってる数値を生成する
 * @param {number} offset 
 * @returns 
 */
const createIsNoteOnThisSendValues = (offset) => { 
  let result = 0;

  // noteの番号が小さいほど桁が小さいほうに入る。つまり、デコード時点では1,2,4...の位を見ていけばよい。
  // これは createIsNoteOnValues() でも共通
  for (let i = 0; i < 32; i++) {
    if (notesActivated[i + offset]) {
      result |= (1 << i);
    }
  }
  return result;
};

/**
 * ノートがオン (※サステインペダルも必要なら考慮) の間はずっとtrueになるような数値を生成する
 * @param {number} offset 
 * @returns 
 */
const createIsNoteOnValues = (offset) => { 
  let result = 0;
  const notesReference = SendSustainPedalBasedValue ? notesOnSustain : notesOn;

  for (let i = 0; i < 32; i++) {
    if (notesReference[i + offset] || notesActivated[i + offset]) {
      result |= (1 << i);
    }
  }
  return result;
};

このスクリプトでは、最終的に8つのnumber値をデータとして送信しています。これは実際には128個ぶんの鍵について「さっき打鍵した」フラグとか「打鍵中」フラグが網羅的に入った256個ぶんのbool値に相当する値を、データサイズをちょっとケチりながら送っています。

Cluster Script (Item Script)

Item Scriptのほうでは以下を行います。

  • 現在の演奏者にあたるプレイヤーの把握

  • Player Scriptから受信したメッセージに基づいた、パーティクルの再生 + 停止

/// <reference path="./index.d.ts"/>

$.onStart(() => {
  $.state.activePlayer = null;
  $.state.actives = [0, 0, 0, 0];
})

$.onInteract((player) => {
  $.log("Set player script...");
  $.setPlayerScript(player);
  $.state.activePlayer = player;
});

$.onReceive((messageType, arg, sender) => {
  if (messageType !== "SetNote") return;

  // アクティブなプレイヤーは最大1人である…とする
  if (sender.id !== $.state.activePlayer.id) return;

  let actives = $.state.actives ?? [0, 0, 0, 0];
  for (let i = 0; i < 4; i++) {
    applyNotes(arg[i], arg[i + 4], actives[i], i * 32);
  }
  $.state.actives = [arg[0], arg[1], arg[2], arg[3]];

}, { player: true, item: false });

// 32個ぶんのnote情報が1つのnumberに入っているのをデコードしながら適用する
const applyNotes = (notes, notesThisTime, actives, indexOffset) => {
  for (let i = 0; i < 32; i++) {
    const isActive = (notes & (1 << i)) !== 0;
    const currentActive = (actives & (1 << i)) !== 0;
    // 打鍵した瞬間だけtrueになる
    const isDown = (notesThisTime & (1 << i)) !== 0;

    const index = i + indexOffset;

    // NOTE: P0~P127とかQ0~Q127といったステート名/シグナル名は、
    // ParticleSystem/Animatorがアタッチされた個別の子要素にキー名として載っている
    const stateName = "P" + index;
    if (!currentActive && isActive) {
      $.setStateCompat("this", stateName, true);
      $.log(`state true: ${stateName}`);
    } else if (currentActive && !isActive) {
      $.setStateCompat("this", stateName, false);
      $.log(`state false: ${stateName}`);
    }

    const signalName = "Q" + index;
    if (isDown) {
      $.sendSignalCompat("this", signalName);
    }
  }
};

とくにパーティクルの再生と停止はAnimatorの制御を通じて行っています。この制御は UnityComponent.setBool 等でもできそうですが、本noteの時点ではベータAPIです。そこで、ここではItemのprefab側に SetAnimatorValueGimmick を用意する前提で、 $.setStateCompat とか $.sendSingalCompat を使って実装しています。

Prefabのセットアップ

prefabは演奏者を指定するインタラクト可能なキューブ、およびパーティクルシステムがついた大量のオブジェクトから構成されます。

本noteのサンプルでは、ノート状態のビジュアライズとして以下を前提にしています。

  • 打鍵している間は何かしら音が鳴ってるはず

  • 打鍵したキーの音は徐々に減衰する

  • サステインペダルを踏んだままで繰り返し打鍵した場合もビジュアル的に分かるほうが嬉しい

これらを考慮したとき、AnimatorControllerの遷移としては下記の4ケースを考えるとよいです。

  • (Trigger or Bool) Off -> On: 演出がスタート

  • (Bool) On -> Off: 演出がストップ

  • Onのまま変化なし: 演出が徐々に減衰

  • (Trigger) On -> On: 減衰していた演出を再び最初からスタート 

これを実際に組んだのが、unitypackageにも含まれているAnimatorControllerです。いちおうフンイキだけスクショで載せておきますが、実物はunitypackageを導入して確認して下さい。


サンプルの改善点

改善点についてです。

まずパーティクル + アニメーションは完全にサンプル扱いで作ってるので、見た目はいい感じにいじって下さい。それも含めて、いかにも改善できそうな点としては下記が挙げられます。

演出:

  • サステインペダルを踏んだときの見せ方をワールド内ギミックで切り替えられるようにする

  • 128個もパーティクルがあるのは多すぎるので、適当に範囲を絞って60個とかに収める

  • (最終的にAnimatorControllerに帰着すればよい、と考えて)パーティクル以外の演出も使ってみる

    • 例: 低音のMIDIノートを参照してSkybox的なマテリアルを切り替える

同期品質:

  • 「一瞬だけ打鍵したキー」のパーティクルが他人から見えにくい事象を緩和するために、Item側でAnimationが有効扱いになる最短時間を保証できるようにする

    • 例: いちどオンにした演出は最短でも0.3秒くらいはオンのままにする


さいごに

「clusterでMIDI信号のビジュアライズ的なのができますよ」という紹介でした。

このnoteは技術的な共有だけでなく「需要調査も兼ねてやってみました」みたいな要素もあります。もし「やってること自体は興味あるし使いたいけど何すればいいか分からん…」という方が居ましたら、ぜひTwitter(X)のDM等からご相談下さい!

Twitter(X): https://x.com/baku_dreameater

いいなと思ったら応援しよう!