この投稿はグレンジ Advent Calendar 2021の8日目の記事です。
概要
株式会社グレンジでクライアントエンジニアをしているGamu(@AblerBiri)です。
今回は、LaunchPadをUnityで扱ってみたという話です。
環境&備品
OS : MacOS Big Sur(11.4)
Unity : 2020.3.12f1
RtMidi for Unity : 1.0.4
LaunchPad : LaunchPad mini MK3
LaunchPadとは?
LaunchPadとは、64個のパッドが付いているMIDIコントローラで、音楽制作や演奏に用いられるハードウェアです。
MacのアプリケーションランチャーのLaunchpadとは別のものです。
画像参考 : https://www.shimamura.co.jp/shop/sapporo-sp/dtm-dj/20210124/5024
音楽制作や演奏だけでなく、純粋な入力端末として扱ったりパッドの色をプログラムで変更したりすることも出来ます。
この入出力はUnityで実装することも出来ます。
ということで、UnityでLaunchPadを制御するためのプラグインの導入と簡単な入出力のサンプルを紹介します。
今回使用したLaunchPadは、LaunchPad mini MK3というものです。
セットアップ
LaunchPad mini MK3のセットアップ
USBでPCに接続します。
Macの場合は特に何もせず、接続されたことが表示されれば完了です。
Windowsの場合はドライバが自動インストールされるかもしれません。
RtMidi for Unityのセットアップ
Keijiro氏の、UnityでMIDIコントローラを扱うためのプラグイン(RtMidi for Unity)を導入します。
Unityのメニューから、Edit > Project Settingsを開きます。
次にPackage Managerタブに切り替えて、画像のようにScoped Registryの情報を入力して、保存します。
Name : Keijiro
URL : https://registry.npmjs.com
Scope(s) : jp.keijiro
Scoped Registryの追加が完了したら、次はUnityのメニューから、Window > Package Managerを開きます。
するとKeijiroというタブがあるはずなので、タブを開き、その中からRtMidiを探してプロジェクトにインポートします。
これでRtMidi for Unityの導入は完了ですが、ほぼネイティブのラップコードのままなので、追加のコードを入れます。
Keijiro氏のRtMidiのリポジトリのAssetsフォルダ以下のクラスを全てプロジェクトに直接取り込みます。
サンプル
入力を受け取るクラスの実装
以下は、Keijiro氏のサンプルコード(MidiInTest.cs)を参考にした入力を受け取るクラスの全文です。
RtMidiInputSource.cs全文
/// <summary>
/// RiMidiを使ったMIDI入力ソース
/// </summary>
public class RtMidiInputSource : IDisposable
{
/// <summary>
/// MIDI入力元データ
/// </summary>
private class MidiInputData
{
public string PortName;
public MidiInPort Port;
}
/// <summary>
/// Midi Probe
/// </summary>
private MidiProbe probe;
/// <summary>
/// Filtered ports
/// </summary>
private List<MidiInputData> ports = new List<MidiInputData>();
/// <summary>
/// Port Filter
/// </summary>
private string portFilter;
/// <summary>
/// Pre-frame Port Count
/// </summary>
private int preFramePortCount;
/// <summary>
/// OnNoteOn Handler
/// args byte:channel, byte:note, byte:velocity
/// </summary>
public Action<byte, byte, byte> OnNoteOn;
/// <summary>
/// OnNoteOff Handler
/// args byte:channel, byte:note
/// </summary>
public Action<byte, byte> OnNoteOff;
/// <summary>
/// OnControlChange Handler
/// args byte:channel, byte:number, byte:value
/// </summary>
public Action<byte, byte, byte> OnControlChange;
/// <summary>
/// Constructor
/// </summary>
/// <param name="portFilter"></param>
public RtMidiInputSource(string portFilter)
{
this.portFilter = portFilter;
probe = new MidiProbe(MidiProbe.Mode.In);
}
/// <summary>
/// Update
/// </summary>
public void Update()
{
if(probe == null)
{
return;
}
if(probe.PortCount != preFramePortCount)
{
preFramePortCount = probe.PortCount;
DisposePorts();
ScanPorts();
}
ports.ForEach(p => p?.Port?.ProcessMessages());
}
/// <summary>
/// Dispose
/// </summary>
public void Dispose()
{
DisposePorts();
probe?.Dispose();
probe = null;
}
/// <summary>
/// Whether real port
/// </summary>
/// <param name="name"></param>
private bool IsRealPort(string name)
{
return !name.Contains("Through") && !name.Contains("RtMidi");
}
/// <summary>
/// Whether pass filter
/// </summary>
/// <param name="name"></param>
private bool IsPassFilter(string name)
{
return name.Contains(portFilter);
}
/// <summary>
/// Scan all Midi ports
/// </summary>
private void ScanPorts()
{
if(probe == null)
{
return;
}
for(var i = 0; i < probe.PortCount; i++)
{
var name = probe.GetPortName(i);
if(IsRealPort(name) && IsPassFilter(name))
{
var port = new MidiInPort(i)
{
OnNoteOn = NoteOn,
OnNoteOff = NoteOff,
OnControlChange = ControlChange,
};
ports.Add(new MidiInputData { PortName = name, Port = port });
}
}
}
/// <summary>
/// Dispose ports
/// </summary>
private void DisposePorts()
{
ports.ForEach(p => p?.Port?.Dispose());
ports.Clear();
}
/// <summary>
/// OnNoteOn Handler
/// </summary>
/// <param name="channel"></param>
/// <param name="note"></param>
/// <param name="velocity"></param>
private void NoteOn(byte channel, byte note, byte velocity)
{
OnNoteOn?.Invoke(channel, note, velocity);
}
/// <summary>
/// OnNoteOff Handler
/// </summary>
/// <param name="channel"></param>
/// <param name="note"></param>
private void NoteOff(byte channel, byte note)
{
OnNoteOff?.Invoke(channel, note);
}
/// <summary>
/// ControlChange Handler
/// </summary>
/// <param name="channel"></param>
/// <param name="number"></param>
/// <param name="value"></param>
private void ControlChange(byte channel, byte number, byte value)
{
OnControlChange?.Invoke(channel, number, value);
}
/// <summary>
/// 現在入力元のターゲットとしているMIDIポートの名前を取得する
/// </summary>
public List<string> DumpCurrentTargetPortNames()
{
return ports.Where(p => p != null).Select(p => p.PortName).ToList();
}
}
このクラスでは、OnNoteOn、OnNoteOff、OnControlChangeの3種類の入力イベントを受け取ることができます。
OnNoteOnはパッド(Note)を押した時に呼び出されます。
OnNoteOffはパッドを離した呼び出されます。
OnControlChangeは特殊なパッドを押したり、つまみがあるMIDIコントローラの値を変えたりした時に呼び出されます。
入力を受け取るサンプル
以下は、パッドを押した時に、そのパッドに対応するノート番号を表示するだけのサンプルです。
RtMidiInputSample.cs全文
public class RtMidiInputSample : MonoBehaviour
{
private readonly string portFilter = "Launchpad Mini MK3 LPMiniMK3 MIDI Out";
private RtMidiInputSource input;
private void Awake()
{
input = new RtMidiInputSource(portFilter);
input.OnNoteOn += OnNoteOn;
}
private void OnDestroy()
{
input?.Dispose();
}
private void Update()
{
input?.Update();
}
/// <summary>
/// パッドが押された時
/// </summary>
private void OnNoteOn(byte channel, byte note, byte velocity)
{
Debug.Log($"チャンネル {channel} ノート番号 {note} 値 {velocity}");
}
}
RtMidiInputSourceのコンストラクタに渡しているポート名は、私のMacでの名称になります。
このポート名はPCによって変わるので、サンプルを試す場合はポート名を確認して下さい。
パッドに対応するノート番号はLaunchPad mini MK3のマニュアルに記載されているものが送られてきます。
例えば、Drumsモードの時に一番左下のパッドを押した時は、36というノート番号が送られてきます。
出力するクラスの実装
以下は、Keijiro氏のサンプルコード(MidiOutTest.cs)を参考にした出力するクラスの全文です。
RtMidiOutputSource.cs全文
/// <summary>
/// RiMidiを使ったMIDI出力ソース
/// </summary>
public class RtMidiOutputSource : IDisposable
{
/// <summary>
/// MIDI出力先データ
/// </summary>
private class MidiOutputData
{
public string PortName;
public MidiOutPort Port;
}
/// <summary>
/// Midi Probe
/// </summary>
private MidiProbe probe;
/// <summary>
/// Filtered ports
/// </summary>
private List<MidiOutputData> ports = new List<MidiOutputData>();
/// <summary>
/// Port Filter
/// </summary>
private string portFilter;
/// <summary>
/// Pre-frame Port Count
/// </summary>
private int preFramePortCount;
/// <summary>
/// Constructor
/// </summary>
/// <param name="portFilter"></param>
public RtMidiOutputSource(string portFilter)
{
this.portFilter = portFilter;
probe = new MidiProbe(MidiProbe.Mode.Out);
preFramePortCount = 0;
}
/// <summary>
/// Update
/// </summary>
public void Update()
{
if(probe == null)
{
return;
}
if(probe.PortCount != preFramePortCount)
{
preFramePortCount = probe.PortCount;
DisposePorts();
ScanPorts();
}
}
/// <summary>
/// Dispose
/// </summary>
public void Dispose()
{
DisposePorts();
probe?.Dispose();
probe = null;
}
/// <summary>
/// Send Message
/// </summary>
public void SendMessage(byte[] bytes)
{
if(probe == null || ports == null || ports.Count < 1)
{
return;
}
ports.ForEach(p => p?.Port?.SendMessage(bytes));
}
/// <summary>
/// Whether real port
/// </summary>
/// <param name="name"></param>
private bool IsRealPort(string name)
{
return !name.Contains("Through") && !name.Contains("RtMidi");
}
/// <summary>
/// Whether pass filter
/// </summary>
/// <param name="name"></param>
private bool IsPassFilter(string name)
{
return name.Contains(portFilter);
}
/// <summary>
/// Scan all Midi ports
/// </summary>
private void ScanPorts()
{
if(probe == null)
{
return;
}
for(var i = 0; i < probe.PortCount; i++)
{
var name = probe.GetPortName(i);
if(IsRealPort(name) && IsPassFilter(name))
{
ports.Add(new MidiOutputData { PortName = name, Port = new MidiOutPort(i) });
}
}
}
/// <summary>
/// Dispose ports
/// </summary>
private void DisposePorts()
{
ports.ForEach(p => p?.Port?.Dispose());
ports.Clear();
}
/// <summary>
/// 現在出力先のターゲットとしているMIDIポートの名前を取得する
/// </summary>
public List<string> DumpCurrentTargetPortNames()
{
return ports.Where(p => p != null).Select(p => p.PortName).ToList();
}
}
このクラスでは、SendMessageで信号を送信することができます。
信号はMIDIコントローラごとに定められたバイト配列のフォーマットで送る必要があります。
LaunchPad mini MK3の送信可能な信号は、以下のプログラマーリファレンスに記載されています。
リンクを開くとPDFがダウンロードされます。
出力するサンプル
以下は、Unityでゲームプレイ時にランダムにパッドの色を変更するサンプルです。
RtMidiOutputSample.cs全文
public class RtMidiOutputSample : MonoBehaviour
{
/// <summary>
/// ユーザモードのノート番号とプログラマーモードのノート番号の対応表
/// 参考:https://fael-downloads-prod.focusrite.com/customer/prod/s3fs-public/downloads/Launchpad%20Mini%20User%20Guide%20JP.pdf
/// </summary>
private readonly Dictionary<int, byte> userToProgrammerNoteMap = new Dictionary<int, byte>
{
{64, 81}, {65, 82}, {66, 83}, {67, 84}, {96, 85}, {97, 86}, {98, 87}, {99, 88},
{60, 71}, {61, 72}, {62, 73}, {63, 74}, {92, 75}, {93, 76}, {94, 77}, {95, 78},
{56, 61}, {57, 62}, {58, 63}, {59, 64}, {88, 65}, {89, 66}, {90, 67}, {91, 68},
{52, 51}, {53, 52}, {54, 53}, {55, 54}, {84, 55}, {85, 56}, {86, 57}, {87, 58},
{48, 41}, {49, 42}, {50, 43}, {51, 44}, {80, 45}, {81, 46}, {82, 47}, {83, 48},
{44, 31}, {45, 32}, {46, 33}, {47, 34}, {76, 35}, {77, 36}, {78, 37}, {79, 38},
{40, 21}, {41, 22}, {42, 23}, {43, 24}, {72, 25}, {73, 26}, {74, 27}, {75, 28},
{36, 11}, {37, 12}, {38, 13}, {39, 14}, {68, 15}, {69, 16}, {70, 17}, {71, 18},
};
/// <summary>
/// LaunchPadへ出力するためのポート名
/// </summary>
private readonly string portFilter = "Launchpad Mini MK3 LPMiniMK3 MIDI In";
private RtMidiOutputSource output;
private void Awake()
{
output = new RtMidiOutputSource(portFilter);
output.Update();
SetColor();
}
private void OnDestroy()
{
output?.Dispose();
}
private void Update()
{
output?.Update();
}
/// <summary>
/// ノートを指定の色に変える
/// </summary>
private void SetColor()
{
// LaunchPad Mini MK3のRGB制御では、
// 240 0 32 41 2 13 3 [(type index R G B) ...] 247
// というフォーマットに従ってバイト配列を構築する必要がある
// 参考:Launchpad Mini MK3 Programmer's reference manual LED lighting SysEx message (p.14)
var bytes = new byte[8 + 5 * 64];
bytes[0] = 240;
bytes[1] = 0;
bytes[2] = 32;
bytes[3] = 41;
bytes[4] = 2;
bytes[5] = 13;
bytes[6] = 3;
bytes[bytes.Length - 1] = 247;
// 64個のノート全ての色を変える
for (var i = 0; i < 64; i++)
{
// ノート番号(36~99)の連番を、プログラマブルモードのインデックス(11~88)に変換
var index = userToProgrammerNoteMap[36 + i];
// 色を0~127の128階調のbyte値でランダムに指定
var r = (byte) Random.Range(0, 127);
var g = (byte) Random.Range(0, 127);
var b = (byte) Random.Range(0, 127);
// 1つのノートの色を変えるのに5byte使う
bytes[7 + i * 5 + 0] = 3;
bytes[7 + i * 5 + 1] = index;
bytes[7 + i * 5 + 2] = r;
bytes[7 + i * 5 + 3] = g;
bytes[7 + i * 5 + 4] = b;
}
// LaunchPadにデータを送信
output.SendMessage(bytes);
}
}
こちらも、RtMidiOutputSourceのコンストラクタに渡しているポート名は、私のMacでの名称になります。
このポート名はPCによって変わるので、サンプルを試す場合はポート名を確認して下さい。
パッドの色を変えるには、LED lighting SysEx messageのフォーマットに従ってバイト配列を送信する必要があります。
具体的には、次のようなフォーマットで送信する必要があります。
240, 0, 32, 41, 2, 13, 3, [(type, index, data) ...], 247
240, 0, 32, 41, 2, 13, 3,
と247
はLED lighting SysEx messageにおける固定値です。
(type, index, data)
の組で1つのパッドの色を変更することになり、この組を可変数で指定することが出来ます。
typeは次の4種類から選択出来ます。
- 0 : 固定タイプ。dataに1つのパレットIDを指定する。(パレットIDはプログラマーリファレンスに記載されている)
- 1 : 点滅タイプ。dataに点滅する2つのパレットIDを指定する。
- 2 : 点滅タイプ。dataに点滅する1つのパレットIDを指定する。
- 3 : 固定タイプ。任意のRGBを指定でき、dataにR,G,Bそれぞれの強さを0~127の範囲で1つずつ指定する。
例えば、一番左下のパッド(インデックス:11)を任意のRGBタイプ(タイプ:3)で赤色(RGB:127,0,0)にしたい場合は、次のようにバイト配列を構築します。
240, 0, 32, 41, 2, 13, 3, 3, 11, 127, 0, 0 , 247
入出力の応用サンプル
最後に、記事冒頭のgifのサンプルを載せておきます。
サンプル全文
using System.Collections.Generic;
using System.Linq;
using UnityEngine;
public class ColorWaveController : MonoBehaviour
{
/// <summary>
/// ユーザモードのノート番号とプログラマーモードのノート番号の対応表
/// 参考:https://fael-downloads-prod.focusrite.com/customer/prod/s3fs-public/downloads/Launchpad%20Mini%20User%20Guide%20JP.pdf
/// </summary>
private static readonly Dictionary<int, byte> userToProgrammerNoteMap = new Dictionary<int, byte>
{
{64, 81}, {65, 82}, {66, 83}, {67, 84}, {96, 85}, {97, 86}, {98, 87}, {99, 88},
{60, 71}, {61, 72}, {62, 73}, {63, 74}, {92, 75}, {93, 76}, {94, 77}, {95, 78},
{56, 61}, {57, 62}, {58, 63}, {59, 64}, {88, 65}, {89, 66}, {90, 67}, {91, 68},
{52, 51}, {53, 52}, {54, 53}, {55, 54}, {84, 55}, {85, 56}, {86, 57}, {87, 58},
{48, 41}, {49, 42}, {50, 43}, {51, 44}, {80, 45}, {81, 46}, {82, 47}, {83, 48},
{44, 31}, {45, 32}, {46, 33}, {47, 34}, {76, 35}, {77, 36}, {78, 37}, {79, 38},
{40, 21}, {41, 22}, {42, 23}, {43, 24}, {72, 25}, {73, 26}, {74, 27}, {75, 28},
{36, 11}, {37, 12}, {38, 13}, {39, 14}, {68, 15}, {69, 16}, {70, 17}, {71, 18},
};
private static readonly Dictionary<int, int> userToArrayIndexMap = new Dictionary<int, int>
{
{64, 56}, {65, 57}, {66, 58}, {67, 59}, {96, 60}, {97, 61}, {98, 62}, {99, 63},
{60, 48}, {61, 49}, {62, 50}, {63, 51}, {92, 52}, {93, 53}, {94, 54}, {95, 55},
{56, 40}, {57, 41}, {58, 42}, {59, 43}, {88, 44}, {89, 45}, {90, 46}, {91, 47},
{52, 32}, {53, 33}, {54, 34}, {55, 35}, {84, 36}, {85, 37}, {86, 38}, {87, 39},
{48, 24}, {49, 25}, {50, 26}, {51, 27}, {80, 28}, {81, 29}, {82, 30}, {83, 31},
{44, 16}, {45, 17}, {46, 18}, {47, 19}, {76, 20}, {77, 21}, {78, 22}, {79, 23},
{40, 8}, {41, 9}, {42, 10}, {43, 11}, {72, 12}, {73, 13}, {74, 14}, {75, 15},
{36, 0}, {37, 1}, {38, 2}, {39, 3}, {68, 4}, {69, 5}, {70, 6}, {71, 7},
};
private static readonly Dictionary<int, int> arrayIndexToUserMap
= userToArrayIndexMap.ToDictionary(m => m.Value, m => m.Key);
/// <summary>
/// LaunchPadから入力を受け取るためのポート名
/// </summary>
private readonly string inPortFilter = "Launchpad Mini MK3 LPMiniMK3 MIDI Out";
/// <summary>
/// LaunchPadへ出力するためのポート名
/// </summary>
private readonly string outPortFilter = "Launchpad Mini MK3 LPMiniMK3 MIDI In";
private RtMidiInputSource input;
private RtMidiOutputSource output;
private Color[] colorArray;
private List<ColorData> colorDataList;
private void Awake()
{
input = new RtMidiInputSource(inPortFilter);
input.Update();
input.OnNoteOn += OnNoteOn;
output = new RtMidiOutputSource(outPortFilter);
output.Update();
colorArray = new Color[64];
ResetColorArray();
colorDataList = new List<ColorData>();
}
private void OnDestroy()
{
output?.Dispose();
input?.Dispose();
}
private void Update()
{
output?.Update();
input?.Update();
ResetColorArray();
ApplyColor();
colorDataList.RemoveAll(data => data.ShouldDestroy());
SendColor();
}
private void OnNoteOn(byte channel, byte note, byte velocity)
{
var r = 0.2f + Random.Range(0f, 0.8f);
var g = 0.2f + Random.Range(0f, 0.8f);
var b = 0.2f + Random.Range(0f, 0.8f);
var speed = Random.Range(0.075f, 0.15f);
colorDataList.Add(new ColorData(userToArrayIndexMap[note], new Color(r, g, b), speed));
}
private void ResetColorArray()
{
for (var i = 0; i < colorArray.Length; i++)
{
colorArray[i] = Color.black;
}
}
private void ApplyColor()
{
// 色の反映
foreach (var colorData in colorDataList)
{
colorData.Update();
if (colorData.ShouldDestroy())
{
continue;
}
var c = colorData.GetColor();
var indexes = colorData.GetColorAreas();
foreach (var index in indexes)
{
var baseColor = colorArray[index];
baseColor.r = Mathf.Min(1, baseColor.r + c.r * c.a);
baseColor.g = Mathf.Min(1, baseColor.g + c.g * c.a);
baseColor.b = Mathf.Min(1, baseColor.b + c.b * c.a);
colorArray[index] = baseColor;
}
}
}
private void SendColor()
{
var bytes = new byte[8 + 5 * 64];
bytes[0] = 240;
bytes[1] = 0;
bytes[2] = 32;
bytes[3] = 41;
bytes[4] = 2;
bytes[5] = 13;
bytes[6] = 3;
bytes[bytes.Length - 1] = 247;
for (var i = 0; i < 64; i++)
{
var userNote = arrayIndexToUserMap[i];
var index = userToProgrammerNoteMap[userNote];
// 色を0~127の128階調のbyte値でランダムに指定
var c = colorArray[i];
var r = (byte) (c.r * 127);
var g = (byte) (c.g * 127);
var b = (byte) (c.b * 127);
// 1つのノートの色を変えるのに5byte使う
bytes[7 + i * 5 + 0] = 3;
bytes[7 + i * 5 + 1] = index;
bytes[7 + i * 5 + 2] = r;
bytes[7 + i * 5 + 3] = g;
bytes[7 + i * 5 + 4] = b;
}
// LaunchPadにデータを送信
output.SendMessage(bytes);
}
private class ColorData
{
private int arrayIndex;
private Color color;
private float wideInterval;
private int radius;
private float wideTimeCount;
private List<int> colorAreas;
public ColorData(int arrayIndex, Color color, float wideInterval)
{
this.arrayIndex = arrayIndex;
this.color = color;
this.color.a = 1;
this.wideInterval = wideInterval;
radius = 0;
wideTimeCount = 0;
colorAreas = new List<int>();
CreateColorArea();
}
public void Update()
{
wideTimeCount += Time.deltaTime;
if (wideTimeCount >= wideInterval)
{
wideTimeCount -= wideInterval;
radius++;
color.a -= 0.1f;
CreateColorArea();
}
}
private void CreateColorArea()
{
colorAreas.Clear();
for (var y = -radius; y <= radius; y++)
{
var checkY = arrayIndex / 8 + y;
if (checkY < 0 || checkY >= 8)
{
// 縦軸範囲外
continue;
}
for (var x = -radius; x <= radius; x++)
{
var checkX = arrayIndex % 8 + x;
if (checkX < 0 || checkX >= 8)
{
// 横軸範囲外
continue;
}
if (Mathf.Abs(y) + Mathf.Abs(x) != radius)
{
// 菱形の形状範囲外
continue;
}
colorAreas.Add(arrayIndex + y * 8 + x);
}
}
}
public List<int> GetColorAreas()
{
return colorAreas;
}
public Color GetColor()
{
return color;
}
public bool ShouldDestroy()
{
return color.a <= 0;
}
}
}
まとめ
今回はUnityでLaunchPad mini MK3を制御する方法を紹介しました。
マニュアルやプログラマーリファレンスを読み込めば、もっと高度な制御が出来るはずです。
LaunchPadをUnityで扱いたい人の助けになれば良いと思います。
参考
https://novationmusic.com/ja/launch/launchpad-mini
https://github.com/keijiro/jp.keijiro.rtmidi
https://fael-downloads-prod.focusrite.com/customer/prod/s3fs-public/downloads/Launchpad%20Mini%20User%20Guide%20JP.pdf
https://www.djshop.gr/Attachment/DownloadFile?downloadId=10737 (PDFがダウンロードされます)