MIDI 再生ライブラリを前々から欲しかったので、
Midy というライブラリを作りました。
ひとまず GM1 の再生に必要な機能は実装しているつもりです。
このライブラリを作る前は FluidSynth を wasm にするのが無難ではあったと思いますが、
wasm サイズが大き過ぎたり、確認が甘いだけかも知れませんが動作に納得がいかないところがありました。
うまく使いこなせなかったので、再生負荷が低く、ライブラリのサイズが小さく、拡張性の高い実装が欲しかったです。
Web での利用を想定して SF3 形式に対応していることは大前提です。
構想段階の話
上記の条件を満たすライブラリの開発は前々から検討していて、
最初はサウンドフォントのパーサを実装してサウンドフォントを読み込み、
@tonejs/midi で MIDI をパースし、
Tone.js で再生処理を実装することを考えていました。
Tone.js を使うとコントロールチェンジの実装が簡単かなと最初は思っていました。
実際にこの構成で基本的な部分はあっさり完成しました。
しかしメインスレッドで実行すると再生負荷が高い問題にすぐ直面しました。
何も考えずにスケジューリングするだけではすぐにラグが発生します。
再生負荷が高いのは短時間にイベント処理を大量に行うからと予想できたので、
setInterval() を使って対象となる音符を探しながら、
AudioWorklet 上でサンプリング単位で処理を行うことで、負荷を低減する実装をしました。
まあまあ動くようになったところで他のライブラリを改めて見てみると、
AudioWorklet を使う実装はほとんどないことを知りました。
AudioWorklet を使う方法もアリとは思いますが、
たいていは AudioBufferSourceNode で十分のようです。
AudioBufferSourceNode を使った MIDI 再生は以下が参考にはなるのですが、
GUI の実装と基盤ライブラリの実装が絡み合っていたり、バグがあったり、中身がよくわからないところが多いので、やはり自作することにしました。
あとゼロから実装していると「その実装じゃ動かないんだわ」にたくさん気付かされたので、やはり採用はできなかったです。
でもまあ私の実装もまだまだ間違いがたくさんあると思うので、じょじょに直していきます。
私が欲しいのはきちんとした再生ができるライブラリです。
他にも色々な実装や fork がありますが、中身がよくわからないところが多いので参考にしてないです。
ちなみに再生負荷を下げるだけなら
js-synthesizer が安定しています。
自作はより良いものを作るためです。
他には FluidSynth や Timidity のコードは当然ながら参考にはなるのでしょうが、苦手過ぎてあまり読めていません。
やっぱ JavaScript のコードってめちゃくちゃ読みやすいし、メンテしやすいんだよなあ。
前準備 (@marmooo/soundfont-parser を作った)
きちんとした再生ライブラリを作るためには色々前準備も必要でした。
まず
@tonejs/midi だと SysEx 命令に対応できないことに気付いたので
midi-file を使って MIDI ファイルをパースするようにしました。
サウンドフォントのパーサは
ryohey/sf2synth.js のコードが綺麗だったのでベースとしながら、SF3 サポートを加えました。
名前はせっかくなので
@marmooo/soundfont-parser にしました。
SF3 サポート以外にも、実装途中で気付いた問題として、パーカッション対応や、軽微な処理速度の向上、規格準拠率の向上、strict mode への対応などもしています。
サウンドフォントで一番謎だったのは音源のサンプリングデータの扱いです。
SoundFont 2.04 の 24bit とかどうやって扱うんでしょうかねこれは…。
そんな型はないので 32bit にするのか、専用の型を用意するのか。
サンプリングデータは通常は 16bit で扱うんだと思いますが、
SF3 サポートや 24bit 対応を考えると Uint8Array が正解かな。
ただこれだとサウンドフォントの仕様を知ってないと扱えなくなる欠点はあります。
とはいえどうせ以下のサウンドフォントの仕様をよく読んで作らないと動かないので、あまり差はない気もします。
しかし読んでもわからないことのほうが多いのがまた問題ですが…。
再生処理の実装
SF3 形式が読み込めるようになった後は、再生処理を作りました。
最初は setInterval() で作っていたのですが、処理が被ると酷いことになることがわかったので、setTimeout() にしました。
しかし setTimeout() もバックグラウンド再生の課題があるとわかったので AudioBufferSourceNode を使ったタイマーを作っています。
このへんの話は、
JavaScript で使えるタイマーのベンチマークを作った が関連します。
タイマー周りは検証が面倒臭かったですが、実装面で苦労したのは Promse 周りです。
Promise は本当に難しくて、たとえば以下の 1行だけでバグります。
const promise = new Promise(...);
return promise;
これは以下のように書かないといけません。
return new Promise(...);
音声処理みたいにタイミングがシビアだと、非同期でない部分をすべて Promise で囲わないとバグります。
1ms くらいのタイムラグの話ですが、音声処理だとこれが致命的な問題になります。
他のアプリで山のように間違ってそうだから直していかないとなあ。
何日か格闘して再生処理をフルスクラッチで書いたら、JavaScript でも十分早いことがわかりました。
途中で気付きましたが、音声処理は大部分がブラウザ上でネイティブ動作するので、
wasm にしたからといって速度が上がる訳ではないと思います。
速度が上がるのはせいぜいバッファー生成だけですが、MIDI では事前生成がほとんどです。
再生はネイティブのタイマーで処理できるので、
JavaScript 側では負荷の小さいスケジューリングで渡せば十分と思います。
より高速な実装を目指す場合、AudioWorklet + wasm でバッファー処理すれば、
リアルタイム性では勝てる可能性があるかも知れませんが、wasm よりネイティブのほうが早いので期待はできないと思います。
ちなみに
Tone.js のコードもかなり眺めたのですが、私の実装と基本的には一緒だと思います。
ただ核心部分では演奏位置のメモリ管理、タイムラインの検索処理、
タイムラインのループ処理あたりの実装が非効率です。
エフェクトとかもチェインを細かく設定しないといけないので、
なんやかんや自作が最強な気がしています。
MIDI Event の実装
根幹部分が完成したらあとはひたすら細かな実装です。
コントロールチェンジや SysEx 命令などの MIDI イベントを地道に実装します。
実装しなければいけないことを、以下で確認しながら作りました。
ただ非常にドキュメントが長く真面目に読んでいるといつまで経っても実装が終わらないため、正直あまりきちんとは読んでない気がします…。
RPN/NRPN 命令は以下も参考にしました。検索しやすい公式情報もほとんどないので、かなり困る。
SysEx 命令は以下も参考にしました。
ただ結局のところ、Web の情報も当てにはならないので、以下の公式情報が参考になるかと思います。
GM1 と GMLite は大差ないですが、GM1 は CC#120 を記載していないように、若干内容が古い印象を受けます。
GM1 サポートをする場合でも CC#120 などは最低限実装したほうが良さそうです。
GMLite の規格書はボリューム周りで致命的すぎる齟齬があったりして非常に困りました。GM2 を読んだほうが良いです。
GM1 の規格書は長すぎるのでまったく読んでいないですが、将来的には読まないといけなそう。
GM1 に対応できて高速・低負荷で再生できれば、シンセサイザーとして 3流には到達と思います。
そこまで実装すれば基本的な実装が正しいことがわかるしコードも安定するはずと思い、まずはそこまで実装して公開しました。
既に基本的な再生は問題なくできるはずで、いまは色々な楽曲を聴きながら細かな調整を加えている段階です。
特にエフェクトは実装が正しいかよくわかっていないので、リバーブやコーラス、ペダルなどの検証が必要です。
あとは細かい最適化や、初期値の設定、細かな追加機能、ベンチマーク、テスト、エラー処理を加えていくのが今後の予定です。
TODO
先に述べたエフェクトは GM2 の話で、既に結構実装はできているのですが、完璧ではない状態です。
GM2 をフルサポートをしようとすると、
Global Parameter Control でリバーブやコーラスの推奨要件が細かく規定されているので、実装の精度が上がっていくと思います。
たぶん一番よくわからなかったコーラス周りの仕様が安定するはずです。
現状の課題としてはリバーブが重いことを認識しています。
複雑な実装はしていないので、単純にリアルタイム処理が重いということだと思います。
MIDI はリアルタイム処理すると、構想段階の実装でもノート再生でさえ遅いことがわかっているので、いかにリアルタイム処理をなくすかが肝となりました。
リバーブの重さも、回避するにはなるべく様々な処理をオフラインでレンダリングしておいて、リアルタイム処理を減らす必要があると思います。
いかにリアルタイム処理をなくすかが肝だからこそ、AudioWorklet を使うのはあまり良い手段ではなさそう。
MIDI はノート・チャネル・マスターと 3層に分かれて音声処理をしていているので、層ごとに最適化が必要そうです。
GM2 だと初期状態でリバーブが掛かっていることを想定しているように見える のですが、これは現状だとちょっと厳しい。
今のところ GM1 対応しかしてないので、リバーブはオフにしています。
リバーブがなくても大量に音符があると稀に重いので、最適化はまだまだ必要です。
あとエフェクトが掛かったときの細かなサウンドフォントの扱いなどもまだまだ完全には実装していません。
やることが山ほどあり過ぎるので、ひとまず基本部分が再生できるようになったところでリリースしたという状態です。
完全に動くまで待つとエターなりそうだし。
まずは GM2 をフルサポートし、将来的には GS/XG の一部にも対応しようとは思っています。
実は GM2 をフルサポートしているライブラリってないんじゃないかな。 FluidSynth とかも対応してないです (SoftPedal を実装しているときに気付いた)。
GS/XG に対応し始めると 1流シンセサイザーという感じで、特にエフェクトのチェインが難しいです。
まずはそこを作る前にバグを潰していく必要があります。
GUI
本格的な GUI に関してはこれから作りたい…と言いたいのは山々ですが、現実的にはそうも言ってられません。
なぜかというと WebAudio は現状ブラウザ上でしか確認できないので、テストをするためには GUI が必要だからです。
仕方ないので簡易的な再生用 UI を作ってデバッグすることにしました。
どうせそのうち作ることになる MIDI 再生 GUI を簡単に作れる
@marmooo/midi-player を作りました。
@marmooo/midi-player を作る時に基本的な操作で必要な API の確認やテストをしているので、
そんなにバグはないんじゃないかと思います。
やはり GUI 化すると細かな API のズレが出がちですが、自分で作っているからこそバグが起きないようにできたのが大きいです。
基本的な再生部分が完成すれば、もう Promise などでハマる可能性がないので、あとはひたすら実装するだけです。
@marmooo/midi-player はもっと高機能にしていくつもりですが、
あまり高機能にし過ぎるよりはライブラリを分けたほうが良いかも知れません。
Midy をリリースするためは、大量のライブラリに手を入れる必要があって、割と時間が掛かりました。
まだ色々と実装が足りないところがあると思いますが、ボチボチ直していきます。
だいたい動くものとして first commit をするまでが、検証が多くて一番大変。
ここからは平凡に実装とバグ潰しです。
現状はデモで動くものを頑張って探すレベルなので、もっと実装の精度を高めていく必要があります。