StandardMIDIFileからピアノロールを表示する その1:SMFの解析
今日は、MIXRecipeで使われているピアノロールについて解説してみます。
MIXRecipeはこちらです
https://github.com/syntaro/javamidimixer/releases
https://github.com/syntaro/javamidimixer/tree/main/mixrecipe
Java標準の、
https://docs.oracle.com/javase/jp/11/docs/api/java.desktop/javax/sound/midi/Sequencer.html
ではピアノロールの表示ですこし不便でしたので、SMFファイルの解析からやっています。
クラス群の要件
・SMFのイベント(音符)を時間範囲を指定して取り出せること
・SMF独特の時間の概念は、1/1000秒単位に変換して、音符を並び替えて管理すること
この要件をみたすためには、Java標準クラスでは物足りない、逆に複雑化してしまいます。
C#でも実装したいので、C#にないものは再発明しておきたかったのもあります。
SMFファイルの構造は以下の順番になっています。
・4文字=マジックナンバー"MThd"
・32ビット=ヘッダーの長さが(バイト数)=固定値6が格納されている
・16ビット=SMFファイルタイプ
・16ビット=トラック数
・16ビット=解像度(後述します)
int res = smfStream.read16();
if (res >= 0x8000) {
res = 0xffff0000 + res;
}
if (res > 0) {
// delta = 四分音符あたりの解像度
_smpteFormat = -99999;
_fileResolution = res;
} else {
// delta = 秒の分数
_smpteFormat = (res >> 8) * -1;
_fileResolution = res & 0xff; // フレーム内の分解能
}
・もしヘッダーの長さが7以上になっていれば、-6したバイト数分=まだ読んでない分スキップします。
それに以下が繰り返されます
・4文字=マジックナンバー"MTrk"を探します
・32ビット=トラックの長さ(バイト数)
・トラックデータ
トラックデータを別のストリームとして切り離して処理しています。
トラックデータは以下の繰り返しです
・可変長数値=経過時間(Tick)
・ステータスコード=0x80以上を読み取った場合のみです。
(もし0x7f以下の場合、前回よみとった、ステータスコードを再利用します。)
・ステータスコードに関連するMIDIメッセージ(2バイト~3バイト)
(2バイト= ステータス0x80, 0x90, 0xa0, 0xb0, 0xe0)
(3バイト= ステータス0xc0, 0xd0)
(可変バイト=ステータス0xf0)
可変バイトのMETAデータ、RESETデータは、構造が少しことなります。
0xf0 + 8ビットタイプコード + 可変長データ長さ + データ
この繰り返しですが、さて、可変長の数値についての説明が必要ですね。
long value = 0; // the variable-lengh int value
int currentByte = 0;
//4ステップ以上のデータはないが
do {
currentByte = read8();
if (currentByte < 0) {
return -1;
}
value = (value << 7) + (currentByte & 0x7F);
} while ((currentByte & 0x80) != 0);
return value;
最上位ビットが立っていれば、それ以外の7ビットを、数値として、
7ビットを、いくつかつなげるということです。
次にタイミングのTickについです。メッセージは、Tickを保持した状態でリストとして保管されています。
リストはソートしておきます。
Tick>格納された順序>プログラムチェンジ>その他ノートなど
Tickからマイクロ秒に変換するのですが、テンポが曲中に出現したら、
それ以降はそのテンポで、マイクロ秒に変換していきます。
参考にさせていただいたQiita
・タカヨシ様のQiita
https://qiita.com/takayoshi1968/items/8e3f901539c92a6aac16
・McbeEringi様のQiita
https://qiita.com/McbeEringi/items/17c24d78fcec2907ef95
Tickとミリ秒を保持したクラスを並び替えておけば、計算は簡単なようです。
_tempoArray = new SMFTempoArray(this);
for (SMFMessage seek : list) {
seek._millisecond = _tempoArray.calcMicrosecondByTick(seek._tick) / 1000;
if (seek.getStatus() == 0xff && seek.getData1() == 0x51) {
_tempoArray.addMPQwithTick(seek.getMetaTempo(), seek._tick);
}
_listMessage.add(seek);
}
として、ミリ秒を埋めていきます。使用するクラス↓
import jp.synthtarou.midimixer.mx36ccmapping.SortedArray;
/**
*
* @author Syntarou YOSHIDA
*/
public class SMFTempoArray extends SortedArray<SMFTempo> {
SMFParser _parent;
/* デフォルトのMPQを記録 */
public SMFTempoArray(SMFParser parser) {
_parent = parser;
SMFTempo tempo = new SMFTempo();
tempo._mpq = 500000; //120BPM
tempo._microsecond = 0;
tempo._tick = 0;
add(tempo);
}
/* Tick以前の、最後のSMFTempoを返す */
SMFTempo backSeekByTick(long tick) {
SMFTempo ret = get(0);
for (int x = 1; x < size(); x++) {
SMFTempo seek = get(x);
if (tick >= seek._tick) {
ret = seek;
}
}
return ret;
}
/* Tickから、Microsecondを求める */
public long calcMicrosecondByTick(long ticks) {
SMFTempo tempo = backSeekByTick(ticks);
double deltaTick = ticks - tempo._tick;
double deltaMicrosecond = deltaTick * tempo._mpq / _parent._fileResolution;
long ret = tempo._microsecond + (long)deltaMicrosecond;
return ret;
}
/* TickベースでMPQを追加 (順番通りに追加する必要がある) */
public void addMPQwithTick(long mpq, long tick) {
SMFTempo tempo = backSeekByTick(tick);
double deltaTick = tick - tempo._tick;
double deltaMicrosecond = deltaTick * tempo._mpq / _parent._fileResolution;
if (deltaTick == 0) {
//上書き
tempo._mpq = mpq;
return;
}
if (tempo._mpq == mpq) {
//変化なし
return;
}
SMFTempo newTempo = new SMFTempo();
newTempo._mpq = mpq;
newTempo._tick = tick;
newTempo._microsecond = tempo._microsecond + (long)deltaMicrosecond;
add(newTempo);
}
/* Microsecond以前の、最後のSMFTempoを返す */
SMFTempo backSeekByMicroSecond(long us) {
SMFTempo ret = get(0);
for (int x = 1; x < size(); x++) {
SMFTempo seek = get(x);
if (us >= seek._microsecond) {
ret = seek;
}
}
return ret;
}
/* Microsecondから、Tickを求める */
public long calcTicksByMicrosecond(long microSecond) {
SMFTempo tempo = backSeekByMicroSecond(microSecond);
double deltaMicroSecond = microSecond - tempo._microsecond;
double deltaTick = deltaMicroSecond * _parent._fileResolution / tempo._mpq;
return tempo._tick + (long)deltaTick;
}
/* MicrosecondベースでMPQを追加 (順番通りに追加する必要がある) */
public void addMPQwithMicrosecond(long mpq, long microSecond) {
SMFTempo tempo = backSeekByMicroSecond(microSecond);
double deltaMicroSecond = microSecond - tempo._microsecond;
double deltaTick = deltaMicroSecond * _parent._fileResolution / tempo._mpq;
if (deltaMicroSecond == 0) {
//上書き
tempo._mpq = mpq;
return;
}
if (tempo._mpq == mpq) {
//変化なし
return;
}
SMFTempo newTempo = new SMFTempo();
newTempo._mpq = mpq;
newTempo._tick = tempo._tick + (long)deltaTick;
newTempo._microsecond = microSecond;
add(newTempo);
}
}
あとは、ミリ秒がきたら、音をならす、次のミリ秒まで待機するといった具合で再生できますが、
ピアノロールの場合、音のないタイミングでも、音を予知してスクロールする必要がありますので、
前回のペイントから、なんとかミリ秒たっていたら、スクロールイベントを発生させるという、
待機ルーチンになります。これは、Y軸の長さ(解像度)と表示する時間の幅によって、設定されるミリ秒です。
ピアノロールクラスには、setTiming(long milliSeconds)を公開しておいてもらえばできます。
StandardMIDIFileからピアノロールを表示する2に続きます。
SMFParser.java
SMFSequencer.java
MXPianoRoll.java(次節で解説)