ある昼下がりの一室

色々と書いたり書かなかったりします

【Processing】SMFを出力する

ProcessingでSMFを出力する方法を紹介します。


環境

Processing 3.5.3


SMFとは

SMFは正式名称Standard MIDI Fileといい、MIDIの演奏データを保存する基本ファイルフォーマットです。拡張子は.midです。SMFはファイルサイズが小さく、データ構造がシンプルであることが特徴です。よく分からんって人はデジタルの楽譜のようなものだと思ってもらえれば良いです。この記事ではファイル構造について適宜説明していきますが、詳しく知りたい方は下記のサイトを参照して下さい。 sites.google.com


Javaのパッケージについて

Javaのパッケージにjavax.sound.midiがあります。これを用いてSMF作成の方法も紹介しているサイトがあります。が、今回はそれを使いません。ライブラリは一切使用せず、自前で書き出しを行っていきます。


SMFを出力する

ここではoutputSMFというSMFファイルを出力する関数を作ります。SMFは、大きく分けてヘッダチャンクとトラックチャンクの2種類に分かれます。outputSMFの引数である配列はトラックチャンクとし、ヘッダチャンクとトラックチャンクを結合し、出力するという形にします。(トラックチャンクの作成は後述します)


ヘッダチャンク

SMFのヘッダチャンクは14byteで構成されています。以下のデータがビッグエンディアン形式で格納されています。(数字は一例です)

チャンクタイプ(4byte) データ長(4byte) フォーマット(2byte) トラック数(2byte) 時間単位(2byte)
4D 54 68 64 00 00 00 06 00 01 00 03 01 E0

チャンクタイプ

これ以降のチャンクのタイプを示すためのアスキーコードです。SMFのヘッダチャンクではMThdです。

データ長

この後にあるデータの長さを表します。ここではヘッダチャンクのデータ長を表しています。(トラックチャンクはトラックチャンクでデータ長を表します)ヘッダチャンクのデータ長は6byteで固定なので「00 00 00 06」でOKです。

フォーマット

SMFのフォーマットは3種類に分類されます。ヘッダチャンクと1つのトラックチャンクで構成されるフォーマット0、複数のトラックを持つフォーマット1、 マルチシーケンスでシーケンスパターンを指定するフォーマット2の3つです。フォーマット2は現在ほとんど使われていないので0か1になります。

トラック数

SMFの全トラック数を表します。フォーマット0なら前述の通りトラックが1つしかないので「00 01」で固定です。

時間単位

分解能と言われている部分です。小節・拍基準か実時間基準か選択でき、最上位ビットが0なら前者、1なら後者です。多くのファイルは小節・拍基準になっています。分解能は1拍(4分音符)を何分割出来るか表しています。上の表は01 E0、10進数に変換すると480です。分解能は480か960であることが多いです。


それでは、関数を作っていきます。

//_listはトラック、_formatはフォーマット、_trackNumberはトラック数、_divisionは分解能、_nameはファイルの名前
void outputSMF(byte[] _list, int _format, int _trackNumber, int _division, String _name) {
  //ヘッダチャンクを格納する配列を作成
  byte[] header = new byte[14];

  //チャンクタイプ
  header[0] = 0x4D;
  header[1] = 0x54;
  header[2] = 0x68;
  header[3] = 0x64;

  //データ長
  header[4] = 0x00;
  header[5] = 0x00;
  header[6] = 0x00;
  header[7] = 0x06;

  //MIDIフォーマット
  header[8] = byte(_format >>> 8);
  header[9] = byte(_format);

  //トラック数
  header[10] = byte(_trackNumber >>> 8);
  header[11] = byte(_trackNumber);

  //分解能
  header[12] = byte(_division >>> 8);
  header[13] = byte(_division);

  //SMF出力
  byte[] file = concat(header, _list);
  saveBytes(_name, file);
}


トラックにデータを書き込む

トラックチャンク

トラックチャンクは以下のデータが入っています。(数字は一例です)

チャンクタイプ(4byte) データ長(4byte) データ本体
4D 54 72 6B 00 00 1A 53 ...

チャンクタイプ

これ以降のチャンクのタイプを示すためのアスキーコードです。SMFのトラックチャンクではMTrkです。

これは単純に追加するだけで大丈夫です。

//チャンクタイプを書き込む
void setInitialTrack(ArrayList<Byte> _track) {
  _track.add(byte(0x4D));
  _track.add(byte(0x54));
  _track.add(byte(0x72));
  _track.add(byte(0x6B));
}


データ長

この後にあるデータの長さを表します。ここではトラックチャンクのデータ長を表しています。

これはデータ長が4byteで表されることやデータ本体のデータ長を格納することに注意しましょう。

//トラックの長さを書き込む
void setTrackLength(ArrayList<Byte> _track) {
  //上記のチャンクタイプを書き込む関数の前に呼び出す場合はindexを4引く,_track.size()-3にする
  _track.add(4, byte((max(0, _track.size()-7))>>>24));
  _track.add(5, byte((max(0, _track.size()-7))>>>16));
  _track.add(6, byte((max(0, _track.size()-7))>>>8));
  _track.add(7, byte((max(0, _track.size()-7)));
}


データ本体

SMFのデータはデルタタイムと3種類のイベント(MIDIイベント、メタイベント、SysExイベント)で構成されています。イベントは命令の種類を表すステータスバイト(1byte)で判断します。


デルタタイム

デルタタイムの後に記述されたイベントを実行までの時間のことです。デルタタイムは最大で4byte設定できます。4byteと聞くと大分少ないと思われる人もいると思いますが、先ほど貼ったリンクによると

デルタタイム 0FFFFFFF というのは、500BPM(拍/分)という速いテンポにおいて、 2 x 106個の96分音符は3日間分にあたり、デルタ・タイムとしては十分な長さである。

とのことなので、4byte使うことは滅多にないです。また、SMFにはたくさんのイベントが設定されているため、毎回4byte使うとデータ量が大きくなってしまいます。そこで、SMFでは可変長数値表現でデルタタイムを記述し、データ量を削減します。

可変長数値表現

可変長数値表現とはその名の通り、データ長が変化する数値の表現方法です。1byteのうち、下位7bitを数値、上位1bit(MSB)をフラグとして扱います。MSBが1なら次のbyteもデルタタイムを表し、0ならデルタタイムを表現するbyteの終了を表します。具体例を出すと、

16進数 2進数 2進数(可変長数値表現) 16進数(可変長数値表現)
7F 0111 1111 0111 1111 7F
80 1000 0000 1000 0001 | 0000 0000 81 00
20 00 0010 0000 | 0000 0000 1010 0000 | 0000 0000 C0 00
3F FF 0011 1111 | 1111 1111 1111 1111 | 0111 1111 FF 7F
40 00 0100 0000 | 0000 0000 1000 0001 | 1000 0000 | 0000 0000 81 80 00

のようになります。

デルタタイムの測定ですが、まずフレーム数をカウントする変数deltaFrameを用意し、draw関数内でインクリメントしていきます。そして、何かイベントが発生したらその数値を可変長数値表現に変換する前のデルタタイムに変換します。フレームレートはデフォルトで60なのでdeltaFrameを60で割り、更にそれを1デルタタイムあたりの秒数で割ること単位を秒に変換します。この値を可変長数値表現に変換した後、トラックに書き込み、deltaFrameを0にするという方法をとります。なお、1デルタタイムあたりの秒数は次のように表されます。

//BPMは1分間あたりの拍の数、divisionは分解能、unitDeltaTimeは1デルタタイムあたりの秒数
int BPM = 120;
int division = 480;
float unitDeltaTime = 60.0 / (BPM * division);


それではコードを書いていきます。

//可変長数値表現に変換したデルタタイムを返す
//_dは変換前のデルタタイム
ArrayList<Byte> returnDeltaTime(int _d) {
  byte[] bytes = new byte[]{0, 0, 0, 0};
  int byteCount = convertDeltaTime(bytes, _d);
  ArrayList<Byte> returnBytes = new ArrayList<Byte>();
  for (int i = 0; i < byteCount; i++) {
    returnBytes.add(bytes[i]);
  }
  return returnBytes;
}

//デルタタイムを可変長数値表現に変換する
int convertDeltaTime(byte[] _array, int _d) {
  int count = 0; //変換時のバイト数
  if (_d <= 0x7F) { 
    //値が1バイトで表せる場合(0x7F→0x7F)
    _array[0] = byte(_d); 
    count = 1;
  } else if (_d <= 0x3FFF) {
    //値が2バイトで表せる場合(0x3FFF→0xFF7F)
    _array[0] = byte(0x80 | ((_d & 0x3F80) >> 7)); 
    _array[1] = byte(0x7F & _d);
    count = 2;
  } else if (_d <= 0x1FFFFF) { 
    //値が3バイトで表せる場合(0x1FFFFF→0xFFFF7F)
    _array[0] = byte(0x80 | ((_d & 0x1FC000) >> 14));
    _array[1] = byte(0x80 | ((_d & 0x3F80) >> 7));
    _array[2] = byte(0x7F & _d);
    count = 3;
  } else { 
    //値が4バイトで表せる場合(0xFFFFFFF→0xFFFFFF7F)
    _array[0] = byte(0x80 | ((_d & 0x0FE00000) >> 21));
    _array[1] = byte(0x80 | ((_d & 0x1FC000) >> 14));
    _array[2] = byte(0x80 | ((_d & 0x3F80) >> 7));
    _array[3] = byte(0x7F & _d);
    count = 4;
  } 
  return count;
}


MIDIイベント

演奏データに関するイベントです。nをチャンネル番号とすると

MIDIステータスバイト 意味
8n ノートオン
9n ノートオフ
An ポリフォニックキープレッシャー
Bn コントロールチェンジ
Cn プログラムチェンジ
Dn チャンネルプレッシャー
En ピッチベンド

となっています。今回は上の2個だけ扱います。この2つは次の組み合わせが1セットとなります。(数字は一例です)

デルタタイム イベント ノート番号 ベロシティ
A1 30 90 60 66

ベロシティは鍵盤を叩く強さ(鍵盤の押し込まれる速度)です。今回はマウスのクリックでノートのオンオフを行うので、強さは関係ないです。そのため、適当な定数として0x66で固定します。

ノート番号

鍵盤の鍵の番号です。0~127の計128段階あります。ノート番号と周波数の関係は下記のサイトを参照して下さい。

www.asahi-net.or.jp

www.g200kg.com


今回は、ノートのオンオフをboolean型変数isPushで管理し、マウスをクリックしたらtrue、離したらfalseにします。その際、トラックに書き込みを行います。また、最初にクリックされたときはデルタタイムを0にしたいので、それを管理する変数isStartを設定します。ノート番号の設定は今回の話の本筋ではないので60に固定します。

int deltaFrame = 0;
boolean isStart = false;
boolean isPush = false;

ArrayList<Byte>track = new ArrayList<Byte>();

void setup() {

}

void draw() {
  if (isStart) {
    deltaFrame++;
  }
}

//ノートオン
void mousePressed(){
  isPush = true;
  writeTrack(track, isPush);
}

//ノートオフ
void mouseReleased(){
  isPush = false;
  writeTrack(track, isPush);
}

//トラックに書き込み
void writeTrack(ArrayList<Byte> _track, boolean bool) {
  if (!isStart) {
    isStart = true;
    //デルタタイム0を追加
    _track.add(byte(0x00));
  } else {
    //デルタタイムを可変長数値表現に変更してから追加
    int deltaTime = int((deltaFrame[_i] / 60.0) / unitDeltaTime);
    ArrayList<Byte> list = new ArrayList();
    list.addAll(returnDeltaTime(deltaTime));
    for (int i = 0; i < list.size(); i++) {
      _track.add(list.get(i));
    }
  }

  //チャンネル
  //false→ノートオフ,true→ノートオン
  int channel;
  if (!bool) {
    channel = 0x80 + _i;
  } else {
    channel = 0x90 + _i;
  }
  track.add(byte(channel));

  //ノート番号;
  int note = 60;
  _track.add(byte(note));
  //ベロシティ
  _track.add(byte(0x66));
  deltaFrame[_i] = 0;
}


SysExイベント

システムエクスクルーシブメッセージを表すイベントです。システムエクスクルーシブメッセージとは、MIDI機器同士でのデータ送信に使用されるメッセージのことです。これのデータ長も可変長なので上記の関数が使えますが、今回はこのイベントを使わないので省略します。


メタイベント

演奏データ以外の情報を用いるためのイベントです。結構種類があるので、詳しい説明は一番最初に貼ったURLから見て下さい。

例えば、曲名(トラック名)を表す場合は次のようになります。

例:曲名がRecordingの場合

デルタタイム ステータスバイト メタイベント データ長 テキスト
00 FF 03 09 52 65 63 6F 72 64 69 6E 67

トラック名を表すメタイベントは次のように書けます。

//トラック名のメタイベントを追加
//_trackはトラック、_nameはトラック名
void setTrackName(ArrayList<Byte> _track, String _name) {
  byte[] bytes = _name.getBytes();
  _track.add(byte(0x00));
  _track.add(byte(0xFF));
  _track.add(byte(0x03));
  _track.add(byte(bytes.length));
  for (int i = 0; i < bytes.length; i++) {
    _track.add(Byte.valueOf(bytes[i])); 
  }
}

また、テンポ変更を表すメタイベントは次のように書けます。

//テンポ変更のメタイベントを追加
void changeTempo(ArrayList<Byte> _track, int _BPM) {
  //4分音符の長さ(単位はμs)
  int quarterNoteLength = int(60.0 * pow(10, 6) / _BPM);
  _track.add(byte(0x00));
  _track.add(byte(0xFF));
  _track.add(byte(0x51));
  _track.add(byte(0x03));
  _track.add(byte(quarterNoteLength >>> 16));
  _track.add(byte(quarterNoteLength >>> 8));
  _track.add(byte(quarterNoteLength));
}

メタイベントはトラック終端を表すもののみ必須です。

//トラック終端のメタイベントを追加
void setTrackEnd(ArrayList<Byte> _track) {
  _track.add(byte(0x00));
  _track.add(byte(0xFF));
  _track.add(byte(0x2F));
  _track.add(byte(0x00));
}

ちなみに、SMFの最初のトラックのことをコンダクタートラックと呼び、フォーマット1の場合はここに演奏データ以外(メタイベントやSysExイベント)を書き込み、実際のデータを2番目以降のトラックに格納するのが一般的です。


なお、トラックの長さは初期状態では不定のためArrayListを使っていますが、出力する関数の引数はbyte型配列なので、ArrayListからbyte型配列に変換する必要があります。

//ArrayList<Byte>からbyte型配列に変換
byte[] arrayListToArray(ArrayList<Byte> _track) {
  Byte[] tempArray = _track.toArray(new Byte[_track.size()]);
  byte[] byteArray = new byte[tempArray.length];
  for (int i = 0; i < byteArray.length; i++) {
    byteArray[i] = tempArray[i];
  }
  return byteArray;
}

ArrayList→Byte型配列→byte型配列と若干面倒な変換をしています。いい方法があれば教えて下さい。

これらを利用することでSMFの出力が出来ます。実装例を下記に示します。


実装例

描画部分について

Processingを使っていることもあり、見た目の部分を簡易的ですが実装していきます。 今回は縦2マス、横8マスのMIDIパッド風のデザインにします。パッドを押すと、押した部分に対応するノート番号とパッドが押されたという状態を、離すと押されていた部分のノート番号とパッドから指が離れたという状態をトラックに書き込む関数に渡す実装にしました。

f:id:my_pon:20200204194243p:plain

見た目はこんな感じです。


コード

コード部分をダブルクリックすると全選択できます。

int BPM = 120;
final int division = 480;
float unitDeltaTime = 60.0 / (BPM * division);
final int format = 1;
final int padNumber = 8;//各行のパッドの数
boolean isStart = false;
final String title = "Recording";
final String fileName = "Recording.mid";

ArrayList<Byte>[] track = new ArrayList[2];

int[] deltaFrame = new int[track.length];
int[][] noteNumber = new int [track.length][padNumber];
boolean[][] isPush = new boolean [track.length][padNumber];

void setup() {
  size(800, 200);
  stroke(255);
  for (int i = 0; i < track.length; i++) {
    deltaFrame[i] = 0;
    track[i] = new ArrayList<Byte>();
    setInitialTrack(track[i]);
    for (int j = 0; j < padNumber; j++) {
      isPush[i][j] = false;
      noteNumber[i][j] = 12 * (i + 5) + j;
    }
  }
}

void draw() {
  background(0);
  deltaCount();
  display();
}

void deltaCount() {
  for (int i = 0; i < track.length; i++) {
    if (isStart) {
      deltaFrame[i]++;
    }
  }
}

void display() {
  for (int i = 0; i < track.length; i++) {
    for (int j = 0; j < padNumber; j++) {
      if (isPush[i][j]) {
        fill(255, 0, 0);
      } else {
        fill(0);
      }
      rect(j * 100, i * 100, 100, 100);
    }
  }
}

void mousePressed() {
  //パッドの内側をマウスボタンで押したら押した部分にあるパッドをノートオンにしてトラックに書き込む
  for (int i = 0; i < track.length; i++) {
    for (int j = 0; j < padNumber; j++) {
      if (j * 100 <= mouseX && mouseX <= (j + 1) * 100 && i * 100 <= mouseY && mouseY <= (i + 1) * 100) {
        if (!isPush[i][j]) {
          isPush[i][j] = true;
          writeTrack(track[i], i, j, isPush[i][j]);
        }
      }
    }
  }
}

void mouseReleased() {
  //マウスボタンを離した時にノートオンのパッドがあればノートオフにしてトラックに書き込む
  for (int i = 0; i < track.length; i++) {
    for (int j = 0; j < padNumber; j++) {
      if (isPush[i][j]) {
        isPush[i][j] = false;
        writeTrack(track[i], i, j, isPush[i][j]);
      }
    }
  }
}

void keyPressed() {
  //スペースキーを押したら保存する
  if (key == ' ') {
    for (int i = 0; i < track.length; i++) {
      setTrackEnd(track[i]);
      setTrackLength(track[i]);
    }

    //コンダクタートラック
    ArrayList<Byte> conductorTrack = new ArrayList<Byte>();
    setInitialTrack(conductorTrack);
    setTrackName(conductorTrack, title);
    changeTempo(conductorTrack, BPM);
    setTrackEnd(conductorTrack);
    setTrackLength(conductorTrack);

    byte[] playData = concat(arrayListToArray(track[0]), arrayListToArray(track[1]));
    byte[] allTrack = concat(arrayListToArray(conductorTrack), playData);
    outputSMF(allTrack, format, track.length+format, division, fileName);
  }
}

//トラックのチャンクタイプを追加
void setInitialTrack(ArrayList<Byte> _track) {
  _track.add(byte(0x4D));
  _track.add(byte(0x54));
  _track.add(byte(0x72));
  _track.add(byte(0x6B));
}

//トラックのデータ長を追加
void setTrackLength(ArrayList<Byte> _track) {
  //上記のチャンクタイプを書き込む関数の前に呼び出す場合はindexを4減らす,_track.size()-3にする
  _track.add(4, byte((max(0, _track.size()-7))>>>24));
  _track.add(5, byte((max(0, _track.size()-7))>>>16));
  _track.add(6, byte((max(0, _track.size()-7))>>>8));
  _track.add(7, byte((max(0, _track.size()-7))));
}

//可変長数値表現に変換したデルタタイムを返す
//_dは変換前のデルタタイム
ArrayList<Byte> returnDeltaTime(int _d) {
  byte[] bytes = new byte[]{0, 0, 0, 0};
  int byteCount = convertDeltaTime(bytes, _d);
  ArrayList<Byte> returnBytes = new ArrayList<Byte>();
  for (int i = 0; i < byteCount; i++) {
    returnBytes.add(bytes[i]);
  }
  return returnBytes;
}

//デルタタイムを可変長数値表現に変換する
int convertDeltaTime(byte[] _array, int _d) {
  int count = 0; //変換時のバイト数
  if (_d <= 0x7F) { 
    //値が1バイトで表せる場合(0x7F→0x7F)
    _array[0] = byte(_d); 
    count = 1;
  } else if (_d <= 0x3FFF) {
    //値が2バイトで表せる場合(0x3FFF→0xFF7F)
    _array[0] = byte(0x80 | ((_d & 0x3F80) >> 7)); 
    _array[1] = byte(0x7F & _d);
    count = 2;
  } else if (_d <= 0x1FFFFF) { 
    //値が3バイトで表せる場合(0x1FFFFF→0xFFFF7F)
    _array[0] = byte(0x80 | ((_d & 0x1FC000) >> 14));
    _array[1] = byte(0x80 | ((_d & 0x3F80) >> 7));
    _array[2] = byte(0x7F & _d);
    count = 3;
  } else { 
    //値が4バイトで表せる場合(0xFFFFFFF→0xFFFFFF7F)
    _array[0] = byte(0x80 | ((_d & 0x0FE00000) >> 21));
    _array[1] = byte(0x80 | ((_d & 0x1FC000) >> 14));
    _array[2] = byte(0x80 | ((_d & 0x3F80) >> 7));
    _array[3] = byte(0x7F & _d);
    count = 4;
  } 
  return count;
}

//トラックに書き込み
void writeTrack(ArrayList<Byte> _track, int _i, int _j, boolean bool) {
  if (!isStart) {
    isStart = true;
    //デルタタイム0を追加
    _track.add(byte(0x00));
  } else {
    //デルタタイムを可変長数値表現に変更してから追加
    int deltaTime = int((deltaFrame[_i] / 60.0) / unitDeltaTime);
    ArrayList<Byte> list = new ArrayList();
    list.addAll(returnDeltaTime(deltaTime));
    for (int i = 0; i < list.size(); i++) {
      _track.add(list.get(i));
    }
  }

  //チャンネル
  //false→ノートオフ,true→ノートオン
  int channel;
  if (!bool) {
    channel = 0x80 + _i;
  } else {
    channel = 0x90 + _i;
  }
  _track.add(byte(channel));

  //ノート番号を取得;
  int note = noteNumber[_i][_j];
  _track.add(byte(note));
  //ベロシティ
  _track.add(byte(0x66));
  deltaFrame[_i] = 0;
}

//トラック名のメタイベントを追加
void setTrackName(ArrayList<Byte> _track, String name) {
  byte[] bytes = name.getBytes();
  _track.add(byte(0x00));
  _track.add(byte(0xFF));
  _track.add(byte(0x03));
  _track.add(byte(bytes.length));
  for (int i = 0; i < bytes.length; i++) {
    _track.add(Byte.valueOf(bytes[i]));
  }
}

//テンポ変更のメタイベントを追加
void changeTempo(ArrayList<Byte> _track, int _BPM) {
  //4分音符の長さ(単位はμs)
  int quarterNoteLength = int(60.0 * pow(10, 6) / _BPM);
  _track.add(byte(0x00));
  _track.add(byte(0xFF));
  _track.add(byte(0x51));
  _track.add(byte(0x03));
  _track.add(byte(quarterNoteLength >>> 16));
  _track.add(byte(quarterNoteLength >>> 8));
  _track.add(byte(quarterNoteLength));
}

//トラック終端のメタイベントを追加
void setTrackEnd(ArrayList<Byte> _track) {
  _track.add(byte(0x00));
  _track.add(byte(0xFF));
  _track.add(byte(0x2F));
  _track.add(byte(0x00));
}

//ArrayList<Byte>からbyte型配列に変換
byte[] arrayListToArray(ArrayList<Byte> _track) {
  Byte[] tempArray = _track.toArray(new Byte[_track.size()]);
  byte[] byteArray = new byte[tempArray.length];
  for (int i = 0; i < byteArray.length; i++) {
    byteArray[i] = tempArray[i];
  }
  return byteArray;
}

//SMFを出力
void outputSMF(byte[] _list, int _format, int _trackNumber, int _division, String _name) {
  //ヘッダチャンクを格納する配列を作成
  byte[] header = new byte[14];

  //チャンクタイプ
  header[0] = 0x4D;
  header[1] = 0x54;
  header[2] = 0x68;
  header[3] = 0x64;

  //データ長
  header[4] = 0x00;
  header[5] = 0x00;
  header[6] = 0x00;
  header[7] = 0x06;

  //MIDIフォーマット
  header[8] = byte(_format >>> 8);
  header[9] = byte(_format);

  //トラック数
  header[10] = byte(_trackNumber >>> 8);
  header[11] = byte(_trackNumber);

  //分解能
  header[12] = byte(_division >>> 8);
  header[13] = byte(_division);

  //SMF出力
  byte[] file = concat(header, _list);
  saveBytes(_name, file);
}