この記事は
M5Stack Advent Calendar 2020
愚者っとCorp.アドベントカレンダー 2020
のクロスエントリです。
M5StickCを使って MIDI over BLEで動作するMIDIコントローラを作ってみました。
下の動画はボタンでDAWの録音開始をトリガーし、デバイスを傾けることでシンセのパラメータを記録しています。
M5StickCでMIDIコントローラ作ってる
— そうた (@SIY1121) December 20, 2020
ボタン押して録音開始、傾きでパラメータ操作ができた! pic.twitter.com/7buOBWaLr7
要約
- BLE over MIDI を実装した
- 9軸IMUと Madgwickフィルタを使って姿勢推定した
- ボタンと傾きで操作するいい感じのUIを実装した
- 状態遷移にStateパターンを使ってみた
全部合体した完成形
https://github.com/SIY1121/m5stickc-state-example
本記事を読むと
- BLE MIDIの大まかな仕様と実装方法
- ライブラリを用いて簡単に9軸IMUで姿勢推定を行う方法
- 混乱しがちな状態管理をStateパターンで単純化する知見
が得られると思います。
環境
- M5StickC
- EnvHat II
- VSCode PlatformIO
- Win10 2004 (macOS Catalinaでも動作確認済み)
また、WindowsでMIDI BLEを使用するには MIDIberryとLoopMIDIが必要です。
Macは追加のソフトウェアなしで利用できます。
M5StickC
M5StickCとはM5Stack社が開発するマイコンユニットです。
様々なM5デバイスがある中で小型なものがM5StickCになります。
今回コントローラにするにあたって小さい方が振り回しやすそうだったのでStickCを選びました。
MIDIとは?
PCと電子楽器を接続するために設計されたプロトコルです。
キーボードの鍵盤を押した・離したといったイベントを始め、様々な情報をやり取りすることができます。
DTMをやってる人にはおなじみですね。
基本的にステータスバイトとデータバイトの2つに大別され、ステータスバイト1つとデータバイト2つの計3バイトで一つのメッセージを表します。(例外あり)
ステータスバイトの上位4ビットには次のようなものがあります。
種類 | 説明 | ||||
---|---|---|---|---|---|
note off | キーが離された | 1 | 0 | 0 | 0 |
note on | キーが押された | 1 | 0 | 0 | 1 |
control change | 様々なコントロールに使用できる汎用的なメッセージを送信 | 1 | 0 | 1 | 1 |
例えば、中央のド(0x3C)が強さMax(0x7F)で押されたメッセージをチャンネル1に送る場合は以下のデータになります。
詳細な仕様はこちらを御覧ください。
MIDI 1.0仕様
MIDI over BLE
上記の仕様はベーシックな5ピンMIDIケーブルでやり取りされるフォーマットです。
一方、MIDIメッセージをBluetooth Low Energyに乗せて飛ばす仕様も策定されました。
MIDIメッセージの手前にタイムスタンプを付け、指定されたサービスUUIDとキャラクタリスティックを通じて読み書きします。
実装
以上を踏まえてM5StickCを最低限MIDIコントロラー(送信のみ)として動作させるコードは以下のようになります。ボタンを押すことでキーボードの真ん中のドを押したメッセージを送信します。
コードはGitHubでもご覧いただけます。
https://github.com/SIY1121/m5stickc-ble-midi-example
#include <Arduino.h>
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEServer.h>
#include <BLE2902.h>
#define MIDI_SERVICE_UUID "03b80e5a-ede8-4b33-a751-6ce34ec4c700"
#define MIDI_CHARACTERISTIC_UUID "7772e5db-3868-4112-a1a9-f2669d106bf3"
#define DEV_NAME "M5StickC"
/**
* BLE over MIDIの簡易実装
*/
class BLE_MIDI: BLEServerCallbacks {
BLEServer *pServer;
BLEService *pService;
BLECharacteristic *pCharacteristic;
BLEAdvertising *pAdvertising;
bool connected = false;
void onConnect(BLEServer* pServer);
void onDisconnect(BLEServer* pServer);
void generateHeader(uint8_t *header);
void send(uint8_t *data);
public:
void init();
void connect();
void noteOn(uint8_t ch, uint8_t note, uint8_t vel);
void noteOff(uint8_t ch, uint8_t note);
void control(uint8_t ch, uint8_t cc, uint8_t value);
bool isConnected();
};
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEServer.h>
#include <BLE2902.h>
#include "ble-midi.h"
/**
* デバイスを初期化し
* BLE MIDI に必要なサービスとキャラクタリスティックを登録する
*/
void BLE_MIDI::init() {
BLEDevice::init(DEV_NAME);
pServer = BLEDevice::createServer();
pServer->setCallbacks(this);
pService = pServer->createService(MIDI_SERVICE_UUID);
pCharacteristic = pService->createCharacteristic(
BLEUUID(MIDI_CHARACTERISTIC_UUID),
BLECharacteristic::PROPERTY_READ |
BLECharacteristic::PROPERTY_WRITE |
BLECharacteristic::PROPERTY_NOTIFY |
BLECharacteristic::PROPERTY_WRITE_NR
);
pCharacteristic->addDescriptor(new BLE2902());
pService->start();
BLEAdvertisementData data = BLEAdvertisementData();
data.setCompleteServices(BLEUUID(MIDI_SERVICE_UUID));
data.setName(DEV_NAME);
pAdvertising = pServer->getAdvertising();
pAdvertising->setAdvertisementData(data);
}
void BLE_MIDI::connect() {
pAdvertising->start();
}
void BLE_MIDI::onConnect(BLEServer *pServer) {
connected = true;
}
void BLE_MIDI::onDisconnect(BLEServer *pServer) {
connected = false;
}
bool BLE_MIDI::isConnected() {
return connected;
}
/**
* タイムスタンプからBLE MIDI のヘッダを生成する
*/
void BLE_MIDI::generateHeader(uint8_t *header) {
unsigned long t = millis();
header[0] = (1 << 7) | ((t >> 7) & ((1 << 6) - 1));
header[1] = (1 << 7) | (t & ((1 << 7) - 1));
}
void BLE_MIDI::send(uint8_t *data) {
pCharacteristic->setValue(data, 5);
pCharacteristic->notify();
}
void BLE_MIDI::noteOn(uint8_t ch, uint8_t note, uint8_t vel) {
uint8_t header[2];
generateHeader(header);
uint8_t data[5] = {header[0], header[1], 0x90 | ch, note, vel};
send(data);
}
void BLE_MIDI::noteOff(uint8_t ch, uint8_t note) {
uint8_t header[2];
generateHeader(header);
uint8_t data[5] = {header[0], header[1], 0x80 | ch, note, 0};
send(data);
}
void BLE_MIDI::control(uint8_t ch, uint8_t cc, uint8_t value) {
uint8_t header[2];
generateHeader(header);
uint8_t data[5] = {header[0], header[1], 0xB0 | ch, cc, value};
send(data);
}
上記の BLE_MIDI
クラスを以下のように使います。
#include <Arduino.h>
#include <M5StickC.h>
#include "ble-midi.h"
BLE_MIDI midi = BLE_MIDI();
void setup() {
M5.begin();
M5.Lcd.setRotation(3);
midi.init();
midi.connect();
}
void loop() {
delay(20);
M5.update();
M5.Lcd.setCursor(0,0);
M5.Lcd.print(midi.isConnected() ? "connected " : "advertising...");
if(!midi.isConnected())return;
if(M5.BtnA.wasPressed())
midi.noteOn(0, 0x3C, 127);
else if(M5.BtnA.wasReleased())
midi.noteOff(0, 0x3C);
}
姿勢推定
姿勢推定とは、デバイスがどのように回転しているか(傾いているか)を推定することを指します。
- M5StickCに搭載されている6軸IMU(加速度・角速度)センサ
- 別売のENV Hatに搭載されている3軸磁気センサ
の計9軸で、M5StickCの姿勢を推定することができます。
原理はこちらで詳しく説明されています。
磁気センサなしでも問題なく推定できるようですが、せっかくなので使ってみます。
センサーの生の値には
- ノイズ
- オフセット
が乗っており、生の値で姿勢推定するのは難しいそうです。
今回は簡単なオフセット除去は自分で実装し、ノイズの除去及び姿勢推定はMadgwickAHRSライブラリを使用しました。
実装
全体のコードはGitHubでご覧いただけます。(以下抜粋です)
https://github.com/SIY1121/m5stickc-madgwick-example
コードの流れを追いやすくするため、センサーを抽象化したクラスを定義しました。
class Sensor {
public:
virtual bool init() = 0;// センサー初期化
virtual void read(float *x, float *y, float *z) = 0;// 値の読み取り
virtual bool calibrate() = 0;// キャリブレーションが完了するとtrueを返す
};
#include <Arduino.h>
#include <M5StickC.h>
#include "sensor/accel.h"
#include "sensor/gyro.h"
#include "sensor/mag.h"
#include <MadgwickAHRS.h>
Madgwick filter;
Sensor *acc = new AccelSensor();
Sensor *gyro = new GyroSensor();
Sensor *mag = new MagSensor();
unsigned long lastUpdate = millis();
float ax,ay,az,gx,gy,gz,mx,my,mz;
void setup() {
M5.begin();
M5.Lcd.setRotation(3);
// センサー初期化
acc->init();
gyro->init();
mag->init();
// 加速度センサーキャリブレーション
M5.Lcd.println("calibration acc");
while(!acc->calibrate()) { delay(2); }
// 角速度センサーキャリブレーション
M5.Lcd.println("calibration gyro");
while(!gyro->calibrate()) { delay(2); }
// 地磁気センサーキャリブレーション
M5.Lcd.println("calibration mag");
while(!mag->calibrate()) { delay(2); }
filter.begin(200);
}
void loop() {
acc->read(&ax, &ay, &az);
gyro->read(&gx, &gy, &gz);
mag->read(&mx, &my, &mz);
// フィルタアップデート
filter.update(gx, gy, gz, ax, ay, az, mx, my, mz);
// 磁気センサを使わない場合
// filter.updateIMU(gx, gy, gz, ax, ay, az);
M5.Lcd.setCursor(0,40);
M5.Lcd.printf("roll %6.2f \npitch %6.2f \nyaw %6.2f", filter.getRoll(), filter.getPitch(), filter.getYaw());
// filter.begin で指定した200Hzになるようにdelayを調節
if(millis() - lastUpdate < 5)
delay(5 - (millis() - lastUpdate));
lastUpdate = millis();
}
加速度・角速度センサのキャリブレーション
これらのセンサーはデバイスが静止している際、値がすべて0であることが期待されます。しかし、微妙に値が乗ってしまっていることがあります。
この場合、静止時のセンサーの値を記録しておけば引いて補正することができます。
下の calibrate()
では、デバイスが静止している前提で1秒間のセンサーの値をすべて合計し、平均することで offset
を算出しています。
そしてread()
で値を返す際にoffset
を用いて補正しています。
#include "accel.h"
#include <M5StickC.h>
bool AccelSensor::init() {
return M5.Imu.Init() == 0;
}
void AccelSensor::read(float *x, float *y, float *z) {
M5.Imu.getAccelData(x, y, z);
*x-=offset.x;
*y-=offset.y;
*z-=offset.z;
}
bool AccelSensor::calibrate() {
if(time == 0) time = millis();
float x,y,z;
M5.Imu.getAccelData(&x, &y, &z);
calibrate_sum.x+=x;
calibrate_sum.y+=y;
calibrate_sum.z+=z;
count++;
if(millis() - time < 1000)return false;
offset.x = calibrate_sum.x / count;
offset.y = calibrate_sum.y / count;
offset.z = calibrate_sum.z / count - 1.0; // 重力加速度
return true;
}
地磁気センサーのキャリブレーション
デバイスをぐるぐる回したときの地磁気センサーの値を3次元にマッピングすると球状になります。しかし球の中心は様々な条件で変化するため、初めにデバイスをぐるぐる回し、センサーの値から球の中心を求める必要があります。
今回は簡易的にx,y,zそれぞれの軸についてセンサーの値の最大値、最小値を記録し、その中点を取ることにしました。
#include "mag.h"
#include <M5StickC.h>
bool MagSensor::init() {
Wire.begin(0,26);
return bmm.initialize() == BMM150_OK;
}
void MagSensor::read(float *x, float *y, float *z) {
bmm.read_mag_data();
*x = bmm.mag_data.x - value_offset.x;
*y = bmm.mag_data.y - value_offset.y;
*z = bmm.mag_data.z - value_offset.z;
}
bool MagSensor::calibrate() {
bmm.read_mag_data();
// 初回呼び出し
if(time == 0) {
time = millis();
value_max.x = bmm.mag_data.x;
value_max.y = bmm.mag_data.y;
value_max.z = bmm.mag_data.z;
value_min.x = bmm.mag_data.x;
value_min.y = bmm.mag_data.y;
value_min.z = bmm.mag_data.z;
return false;
}
// 最大値最小値を更新
value_max.x = max(value_max.x, bmm.mag_data.x);
value_max.y = max(value_max.y, bmm.mag_data.y);
value_max.z = max(value_max.z, bmm.mag_data.z);
value_min.x = min(value_min.x, bmm.mag_data.x);
value_min.y = min(value_min.y, bmm.mag_data.y);
value_min.z = min(value_min.z, bmm.mag_data.z);
// 20秒立つまで以下は実行しない
if(millis() - time < 20000 )return false;
// 球の中心位置を計算
value_offset.x = (value_max.x + value_min.x) / 2;
value_offset.y = (value_max.y + value_min.y) / 2;
value_offset.z = (value_max.z + value_min.z) / 2;
return true;
}
MIDI x 姿勢推定
これまでのBLE MIDIと姿勢推定を組み合わせると、傾きに応じてMIDIメッセージを送信することができます。
先程の例ではドの音を鳴らすメッセージを送信しましたが、実際には control change
メッセージを送信するようにしました。これは汎用的的に利用できるメッセージで、ある程度用途は決まっているものの、使用するソフトでマッピングを自由に変えることができます。(例:CC4に値がきたら録音を開始する。CC12の値をシンセのパラメータとして使う、等)
M5StickCのディスプレイとボタン
M5StickCには小さいですが 160x80のカラーディスプレイがあります。
黒背景に白文字では味気ないので、見た目を良くしつつ任意のCCを送れるように設定画面を用意しました。
M5StickCはボタンが少ない!
M5StickCはプログラムで使用できるボタンは2つ(BtnA, BtnB)しかありません。
そのため設定画面を操作するのはなかなか厳しいです。
そこで今回せっかく姿勢推定をしているので、デバイスを傾けてメニュー項目の選択を行ってみました。
M5StickCはボタンが少なくて操作が辛いので傾けて項目選択できるようにしてみた pic.twitter.com/8Rl11VzRkQ
— そうた (@SIY1121) December 22, 2020
状態遷移にStateパターンを使ってみる
キャリブレーション画面やメイン画面、設定画面など多くの状態を持つと状態管理が厳しくなってきます。特にmain.cppにすべて書いていると収集がつかなくなります。
そこでStateパターンを使ってみます。
Stateパターンとは状態を表すオブジェクトを導入し、そのオブジェクトが変化することで振る舞いを変化させる方法です。
今回は状態毎にクラスに分け、関数の戻り値で次の状態を返すようにしました。
すると
- 状態毎にスコープが分かれる
- 状態遷移が起こる場所がreturnステートメントのみになる
となり状態遷移を追いやすくなったり、バグが発生しにくくなるメリットがあります。
試しにタイマー(StateA)とカウンター(StateB)という2つの機能を持つアプリをStateパターンで実装してみます。
全体のコードはGitHubでご覧いただけます。
https://github.com/SIY1121/m5stickc-state-example
状態の基底クラスState
#pragma once
enum StateNo {
A,
B
};
class State {
public:
virtual StateNo exec() = 0;
};
タイマーを実装するStateA。
#include "state.h"
#include <M5StickC.h>
class StateA: public State {
bool active = false;
unsigned long time = 0;
public:
StateNo exec() override;
};
StateNo StateA::exec() {
M5.Lcd.drawCentreString("Timer", 40,50,4);
M5.Lcd.drawNumber(active ? (millis() - time) / 1000 : 0, 0 , 80, 7);
// ボタンBが押されたらカウンターに切り替え
if(M5.BtnB.wasPressed())
return StateNo::B;
// ボタンAが押されたらタイマースタート・リセット
else if(M5.BtnA.wasPressed()) {
if(!active) {
active = true;
time = millis();
}
else {
active = false;
M5.Lcd.fillScreen(TFT_DARKCYAN);
}
}
return StateNo::A;
}
カウンターを実装するStateB
#include "state.h"
#include <M5StickC.h>
class StateB: public State {
int counter = 0;
public:
StateNo exec() override;
};
StateNo StateB::exec() {
M5.Lcd.drawCentreString("Count", 40,50,4);
M5.Lcd.drawNumber(counter, 0 , 80, 7);
// ボタンBが押されたらタイマーに切り替え
if(M5.BtnB.wasPressed())
return StateNo::A;
// ボタンAが押されたらカウント
else if(M5.BtnA.wasPressed())
counter++;
return StateNo::B;
}
#include <Arduino.h>
#include <M5StickC.h>
#include "state/state.h"
#include "state/stateA.h"
#include "state/stateB.h"
State* stateMap[] = {
new StateA(),
new StateB()
};
StateNo current = StateNo::A;//初期状態
void setup() {
M5.begin();
M5.Lcd.setTextColor(TFT_WHITE, TFT_DARKCYAN);
M5.Lcd.fillScreen(TFT_DARKCYAN);
}
void loop() {
M5.update();
// 現在の状態を実行して次の状態を得る
StateNo next = stateMap[current]->exec();
if(current != next) {
current = next;
M5.Lcd.fillScreen(TFT_DARKCYAN); //画面クリア
}
delay(10);
}
サイドのBtnBを押すとタイマーとカウンターを切り替えることができます。
機能毎に分割したことでmain.cpp
が膨れず、また互いの機能が干渉することもなくなりました。
補足
この方法だとシンプルですが、enum StateNo
と State* stateMap
内の順番を合わせる必要があり、ヒューマンエラーが発生する可能性があります。
可能であれば各Stateクラスをシングルトンにしてクラスそのものを状態として保持するべきだと思います。(というよりその方が普通な気がしますが、c++初心者なので断念しました、、、)
最終形態
- MIDI over BLE
- 姿勢推定
- UI実装
- Stateパターン
をすべて統合することで冒頭の動画のプログラムが完成しました。
https://github.com/SIY1121/m5stickc-midi-controller
これをビルドして書き込むとM5StickがBLE MIDIコントローラとして利用できます!
終わりに
M5StickCは本体に様々なモジュールが乗っているのと、拡張も非常にやりやすいので、簡単にマイコン入門できるとても良い製品だと思います。
気になった方は是非試してみてください。
参考文献
MIDI 1.0仕様
Bluetooth Low Energy解説
MIDI over BLE 解説
M5Stackで学ぶ「9軸センサ制御」