一般的にMIDIファイルをC#などで読み込みたい時は専用のライブラリを使うことが多いと思う。 だけど、それらをあえて使わずにバイナリデータを直接読み込んで使いたい。
そんな話を備忘録も兼ねて書いてみる。ちなみに言語はC#。

なんで直接読むの?

理由はいろいろあるけど、思いつくのを書いてみる。
重そう、でかいファイルを扱えない、使う目的に合わせて最適化したい、自分で解析して扱ってみたい,,,etc
その中でも一番なのが使う目的に合わせて最適化したいという部分。 MIDIと言っても通常のMIDIファイルではなく、Black MIDIと呼ばれる数千、数億ノーツにまで及ぶ巨大なMIDIファイルををプログラムで扱おうとしたとき、 おそらく普通のライブラリでは重かったりメモリ不足になったりすると思われる。 そこで読み込み方、データの保持の仕方、それらの処理の仕方などを自分で制御できれば、巨大なMIDIファイルでもちゃんと扱えそうだと思い、直接読む手段を選んだ。

まずはファイルを読み込む

最初扱ってた時はFile.ReadAllBytes()で全て配列に格納し、ループで1バイトずつ読むという方法を採用していた。 だけど、この方法では最大で2GBのファイルまでしか読めないということに気づいた。 次にStreamを使って読む方法を試し、これなら数MBずつ読んだりできるのでどれだけでかいファイルでも問題なく読むことができるようになった。 今のところ思いつく方法はこれくらいかな~。

MIDIの構造について

バイト単位で読む以上、MIDIの構造を理解していないと何も始まらない。 そこで、私は以下のページを参考にしてMIDIの構造を理解し、プログラムで扱えるようになった。(作者様には感謝)

thumbnail
Welcome to yyagi's web site. - SMF (Standard MIDI Files) の構造

ヘッダートラック

まず最初に必ず来るのがヘッダートラックと呼ばれる、MIDIファイルの基本情報を書くための領域。 14バイト固定なので、ここのデータが特に不要の場合はいきなり15バイト目から読む場合も。
最初の4バイトは固定で必ず「4D 54 68 64」が来る、そうじゃない場合はMIDIファイルじゃないか壊れているだろう。
次の4バイトがこの次に続くヘッダートラックのデータ部分のバイト数、残りは6バイトで固定なのでここも必ず「00 00 00 06」となる。
次の2バイトがIDIのフォーマット指定、0が1トラックに全てを詰め込む方式で、1がマルチトラック、2は見たことないからわからん。
次の2バイトが全体のトラック数。MIDI全体を読まなくても、ここを見ればこのMIDIにはいくつトラックがあるかを把握することができる。 2バイトまでなのでMIDIファイル上での最大トラック数は65535トラックまでということになるのかな。
最後の2バイトがデルタタイムの指定。最上位ビットによって意味が変わるが、0の場合でしか見たことないので1はわからん。 0の場合は、残りのビットに分解能のデータが入る。分解能とは、MIDIの最小単位であるtickが1拍中にいくつ入るかを示したもの。 480と入っていた場合は1小節が1920tickに分割されることになる。

実データトラック

ここからは実際にノーツデータが格納される実データトラックが続いて行くことになる。
最初の4バイトは固定で必ず「4D 54 72 6B」が来る。
次の4バイトにこの後続く実データが何バイトあるかを格納する。4バイトなので1トラックの最大データ数は約4GBとなる。
ここからは実データとなるが、大まかに以下の構造となっている。
最初の1~4バイト:可変長でデルタタイムが入る。デルタタイムとは直前のデータから次のデータまで何tick離れているかという情報。 次の1バイト:ステータスバイトと呼ばれるもので、上位4ビットに次の情報を示したMIDIイベント、下位4ビットにチャンネルが格納されている。 その次のバイト:ステータスバイトごとにデータ構造が変わるため、条件分岐して処理を分ける必要がある。

ランニングステータス

先ほどのステータスバイトは、一部省略できるものがある。 そもそもステータスバイトは上位4ビットがかならず8以上となり、最上位ビットが1になる。 そして、省略が許されているステータスバイトの次の1バイトは必ず最上位ビットが0となる。 なので、ステータスバイトを読んだ時に最上位ビットが0だった場合、ランニングステータスが適用されているということになるので、直前のステータスと同様だとして解釈する必要がある。

これらの構造を理解して置けば、MIDIファイルを読み書きできるということになる。 まずデルタタイムを読み、ステータスバイトを読み、それに合わせて次のデータを読み、次のデルタタイムに進む、、、といった流れの繰り返しで呼んでいくことになる。

処理速度を上げるための工夫

勉強したての頃は、わかりやすくするためバイトを判定するときは文字列化して比較していた。 midiData[]という配列にcntという変数でインデックス指定して読んでいた場合、
if (midiData[cnt].ToString().Substring(0, 1) == “B” && …というようなことをしていたため、まぁ遅い。 そこでいろいろ調べた結果ビットシフトやビット演算というものを知り、
if (midiData[cnt] » 4 == 11 && …のように置き換えたところ劇的に早くなった。体感数倍以上速くなったのでかなり驚いた記憶。
それ以外は分割読み込みするときに数MB単位にしたり、ProgressBarへの表示を間引いたりすることで処理速度を上げていた。

MIDIを加工したりするツールを作った

最初はMIDIを読んでいろいろ加工できるツールを作ってた。
元々はMIDIを合成して一つにする目的だったのでアプリ名は適当に「MIDIMerge」にしていたが、どんどん機能を付け足していって何のツールなのかよくわからなくなってしまった。
主な機能は以下の通り
ノート抽出:あらゆるイベントからノートデータのみを抽出(最低限必要なテンポデータ等を除く)
ノート圧縮:MIDIデータに対してランニングステータスを適用する
MIDI分割:各トラックごとに分割してトラック数分のMIDIファイルを出力する
ノート結合:指定したGate以下のノートに対して、指定したtick以下の間隔で連続しているノート同士を結合する
重複削除:ノート同士が重なっていた場合は1つになるまで削除
MIDI合成:複数のMIDIデータを合成して1つのMIDIファイルを出力
音ブロック出力:こちらで詳しく書いているが、Minecraftの音ブロック演奏用のデータを出力する。あっちのページで他の機能もいろいろあると言っていたのはこれらの機能の話だったりする。

MIDIプレイヤーを作った

いろいろ読めるようになったのなら再生も行けるんじゃね?って思って作り始めたのがこちらで詳しく書いているMIDIプレイヤー。 あんなに上手く作れるとは思ってなかったのでそこそこ達成感はあったね~。

今後作ってみたいもの

Black MIDI作成に特化したDAWとか作ってみたさあるけど、流石に難易度が高そうな気がしてる。 ベースはDominoのような操作感で、MIDI Artをパーツ単位で配置し、細かさや隙間などを指定して最終的にMIDI出力するというもの。 そうすれば編集中は軽くて済んで、より大規模なBlack MIDIを手軽に作れるようにならないかな~なんて思ってたり。