portmidi-sharpを試験的に使って作ろうと思っていた小物ツールが2つある。昨日それらの基本版がどちらも完成した。portmidi-sharpのリポジトリに入っている。
ひとつは単純なSMFのプレイヤーで、SMFを解析してportmidiで出力するだけ。間はThread.Sleep()しているだけなので、微妙に遅延が発生するので、理想的とは言えないのだが、実質的にあまり困るところまではいっていないと思う。
小物なのだけど、実はいろいろバグに悩まされて、時間がかかってしまっている。一番困ったのはパフォーマンスが出なくて命令の取りこぼしが発生すること...だと思っていたのだが、実は命令の取りこぼしなどではなかった。パフォーマンスの問題だとしたら面倒だと思いながらコードの改善にあたる。
プレイヤーは、ひとつのイベントリストを受け取って、デルタタイムを見ながらThread.Sleep()をかけて、時間が来たら次の待ちまでの命令を全部処理する、という仕組みになっている。トラックごとにスレッドを使うのも無駄だ。このため、プレイヤーの中では、複数トラックを一つのトラックにマージする処理を行っている。まず全てのイベントの時間単位を先頭からのデルタタイムに変換した、長大なイベントのリストを作って、そのデルタタイムでList<MidiEvent>.Sort()して、最後に絶対値にしたデルタタイムを相対値に戻すようにした。
この処理が長くて問題が多そうなので、まずportmidi-sharpのMidiEventに変換してからマージする仕組みを捨てて、SMFサポートのライブラリでSmfTrackMergerというのを作ってSmfEventのレベルで並べ替えることにした。結果的にこれはSMF Format 0からSMF Format 1へのコンバータとしても使えることになった。
実装をSmfEventで書き換えても相変わらず命令のとりこぼしのような状態は続いたので、取りこぼしがデータのレベルで発生しているのではないかと思ってコードを数日間眺めた(これだけ眺めていたわけではないが)。そして昨日唐突にソートがまずいことに気がついた。
たとえば、MMLで "@5 q0 v100 o5 ccc" ようなシーケンスがあったとする(q0は減算ゲートタイムなしを表すとする)。このとき、ソートされる前の命令は次のようになる: C005 906064 ... 806000 906064 ... 806000 906064 ... FF2F00 (...はデルタタイムによる待機)
"..." ごとに区切られたMIDIメッセージは、先頭からの絶対デルタタイム(命令送信時間)こそ同一だが、その順序は維持されていなければならないのだ。これまでの実装では単純にList`1.Sort()を使っていたから、維持されなければならない順序が失われていたことになる。上記の例だと、note onしてから音色を変更したり(変更前の音色で発音してしまう)、note onしてから直前のnote offが続く(直前の発音も同じキーだと、発音したばかりのnoteも消えてしまう)といった問題になる。
そういうわけで、ソートの実装を変更して、待機時間ごとに区切ったインデックスをもとにブロックを作って、ブロックレベルでソートしてから、ブロックインデックスをもとにイベントリストの再構築を行う、というやり方にしたら、この「取りこぼし」問題はあっさり解決した。
ちなみに、Format 1 -> Format 0の変換を実装したので、それならば逆もアリなのではないか?とふと考えて実装してみた。基本的には、チャネルごとにトラックを割り振るという仕組みにしてあって(SysExやMetaは別のマスタートラックを用意)、MIDI命令ごとに出力するトラックをオーバーライド出来るような関数にしてある: pubic virtual int GetTrackNumber(SmfEvent)
これを活用すれば、たとえば、noteon/noteoffとその他の命令を全て切り離すようなカスタムコンバータを作ることも可能だ。そうするとかなりイベントリスト型のシーケンサに優しくなるだろうし、今後SMFからMMLへの逆コンバータを作る時にも重宝しそうだ。
プレイヤーについてはまだ多少やるべきことがあるのだけど、とりあえず基本中の基本は出来たので安堵している。これもテンポ計算に使うデルタタイム指定をいじくると、簡単に倍速再生だのスロー再生だのが出来ておもしろい。もちろんプレイヤークラスではMIDIイベント出現時の処理をオーバーライドできるようにしてある。
Leave a comment