8月の開発記録 (2022)

ちょっとだけ台湾生活のようす

台湾で日常生活を送りながら日暮しPCに向かいて過ごしています。日本も医療崩壊がひどいことになっていますが、安全に過ごせる度合いは台湾とどっこいどっこいなところがあるなあと感じています。公共交通機関でマスクを外す人間は皆無に近いので(これはきちんと規制があるためでしょう)、日本とは違って安全にMRTやバスで移動できています(日本では2ヶ月に1回くらいしか載っていなかった)。MRTはもともと飲食禁止で、日本みたいにホームに自販機も売店も設置されていないですし。

最初の1ヶ月はそこそこ他人と会うことがあったのですが、最近は外出する理由もなく、ほとんどYouBike(貸自転車)しか使っていません。いつの間にかYouBike 2.0とやらができて、大抵500mも歩かないうちにバイクスポットが見つかるくらい増えているので、ちょっとGoogle Mapで茶店(作業場)を探して空いていたら(空いているかどうかはざっくりGoogle Mapsアプリでわかる)行ってみるか…みたいなことは簡単に出来ています。昔は短時間なら完全無料でしたが、今もほぼ毎回5NTしか払ってないしほぼ無料です。昔は自転車を買っていましたが今は買わなくてもいいな…となっています。町中でもYouBike以外の自転車をだいぶ見なくなった気がします。

安全なのは移動くらいで、食・住は日本のほうが安全だなあと思います。もともと日本人は家庭持ち以外はたいがい一人暮らしだと思いますが、こっちではたいがい複数人でルームシェアリング(正確にはflat sharing)で暮らしていますし。わたしも7月は本当はそうなる一人暮らしになる予定だったのですが、airbnbのホストとのやり取りで完全に騙されて、いざ行ってみたら他の住人が…みたいなことになっていて、しかも初日から隣の部屋から止まらない咳が聞こえてくる…みたいな恐怖体験で、もう自室に入るたびにアルコール消毒するストレスフルな生活でした。家族や自分の同意のある知人ならともかく、どこで何をしているかもわからんどこの馬の骨とも知れぬ輩から伝染されるのはさすがに…。今は完全に自分1人しかいない部屋を借りているのですが、日本の自宅並みに安全です。ただ当然ながら家賃が高くて、今は日本並みに払っています。これはサステナブルではない…

食が安全ではないと思うのは、特に国レベルで圧倒的に自炊に向いていないためです。キッチンのない部屋も多いし(たとえば591で部屋探しの検索条件に「可開伙」 = 料理可能 を追加するとそこそこ絞り込まれます)、食材は日本以上に高いものも多いし、外食したほうが総合的に安いと言っていいでしょう。そんなわけで多くの人にとっては外食が基本になりますし、飲食店は混んでいます。持ち帰りが簡単にできるのはいいところですが、料理を作っている側も日本に比べたら感染対策は緩いですね。店員は休憩中に店内でマスクを外して過ごしていることが多く、何なら調理中も…みたいな様子を、日本より頻繁に見かけます。弁当類は東京のコンビニ同様に台北のコンビニでも買えるので、それで毎日過ごせるというのであれば、こっちは十分安全でしょう。

引越し前は住居付近に便當の店がたくさんあって毎日ローテーションで健康的なものを買えたのですが(共用のキッチンなんて使う気になれなかったし)、今はほとんどなくて代わりに夜市が近くにあるので、たまに油断してその辺で買って帰るか、数少ない便當の店まで足を延ばすか、ランチだけ外でちょっとお高いブランチの類(大抵200NT超え)を食うか、チェーン店(バーガーキングとか)で済ませるか、自宅にある大同電鍋やIHホットプレートを使ってできることだけやる(ただし調味料が何もない)…みたいな生活です。日本では最近ほぼ毎日自宅で食事を作っていたので、天地の差です。

AAP: port-config拡張(未了)と自動定義ポート

6月頃から始めたAAPクライアントの再構築(ネイティブコード中心化)を皮切りに、state拡張をextension化して、7月にはAIDLによる初期化プロセスに破壊的変更を加えましたが、8月にもまずこれを応用したプラグインインスタンスメタデータによらないポートの生成を実現しました。これはport-config拡張機能として実装される予定ですが、現状では大したことをやっておらず、この拡張機能が使われていたら<ports>要素をプラグインメタデータres/xml/aap_metadata.xmlに書かなくてもよろしくやってくれる、というものです。

本来はこれでオーディオポート生成も自動調整できることが期待されるのですが、ひとまずはこれでポートの定義を自動生成中心に置き換えることを目論んでいます。今後「ポートが自動生成されるように作成されたプラグインのであれば、新しいポート設計にも自動で対応する」ような変更を加えやすくなりますし(現状ではポートをハードコードすることになるので、コード上の後方互換性は間違いなく維持できませんが)。

port-configに関連する話でいうと、これ自体はオーディオバス(ポート)設定の自動調整を主眼においたものなのですが、プラグインのオーディオ処理の中心process()やその前段階としてのprepare()に渡すオーディオバッファポインターの集合体AndroidAudioPluginBuffer構造体の構成についても、再設計すべきか否か常日頃から悩んでいて、結論が出ません。CLAPのようにdata32data64を分けたりports x channelsの論理単位を分ける設計が魅力的なので模倣するのも良いと思っています。特にportごとにlatencyを設定できるのが美しいですね。ただこのノリでstrongly-typed APIを導入してコロコロ破壊的に変更し始めると多分癖になって良くない…という警戒心があります。これまでAAPは2年近くプラグインAPIレベルで破壊的変更を加えてこなかったので。6月からどんどん崩していますが(今だけ…というつもり)。

AAP UMP based parameters

今月はこの流れでいよいよパラメーター変更を1つのMIDIポートからすべて処理する仕組みを実装しました。LV2でいえばAtomポート、CLAPでいえばclap_process_tevents_inevents_outに相当するやつです。これでOdin2みたいに数百個のパラメーターがあるやつが数百個のパラメーターポートを生成してそれぞれにバッファが付く、みたいなことをする必要が原理的には不要になります。

原理的に…というのは、実のところこれがまだaap-juceやaap-lv2には適用されていないというのと、メタデータによらないポート生成との食い合わせが悪く、プラグインプレビュー( = テスト)のActivityでMIDIシーケンスを再生できていないためです。現状、MIDIシーケンスはMIDI1で生成されていて、MIDI1イベントとして送信されていますが、新しいプラグインパラメーター変更はMIDI2のAssignable Controllerを利用しており(bank/indexでパラメーター番号、値はfloatのビットパターンを32bit intとしてそのまま反映)、MIDI1に変換すると情報落ちが発生します。floatの列挙値で不一致が発生することを考えるとこれは容認できません。

MIDI1ポートとMIDI2ポート(あるいはパラメーター変更などに特化したポート)を並立させることも考えられますが、パラメーター変更イベントはもともとtimestampedでなければならないことを考えると、MIDI1ポートと並立していることは大きなデメリットになります。これはCLAPのWebサイトにあるMIDI & Event Queuesというページにある解説が詳しいですが、特にVST3のパラメーター変更とノートを別々のイベントキューに分岐するやり方ではイベントの処理順序が維持されないという問題があります。これを回避するためにsample accurateだったはずのイベントを1サンプルずつずらすみたいなdirty hackが用いられるわけです。

そういうわけで、MIDIサポートは、ホスト-プラグイン間のプロトコルとしては全て1本のMIDI2ポートでやらざるを得ない気がしています(プラグインAPIとしてはUMPのみ用いて、MIDI1への変換はプラグイン開発サポートライブラリみたいなものを作って対応する)。現行のMIDI1サポートもSMFのtimecodeをそのまま送るようなやっつけ仕様で、プラグイン側が複数のtimecode decoderを持つのは負担にしかならないはずですし。

最近CLAPのコミュニティでやり取りしていて知ったのですが、CLAPではMIDIなどのイベントポートを複数使えないわけではないんですね。ただホストとプラグインを繋ぐ論理ポートは1本でなければならず、その中で流れる各種イベントにはこれとは別の概念であるportが存在するようです。たとえば、Kontaktのport 1〜port 8に、あるいはSC-88ProのPort AとPort Bに(めっさ古い例えだ…)、同時にイベントを送信できるわけです。追記: イベント構造体にport_indexとして指定するスタイルです(clap_plugin_note_ports_t.get()で渡すindexに対応するはず)。

プラグインパラメーターの変更とプラグインからの変更通知に関してはUIが絡む場合も考えなければならず、特にAAPの設計ではホストからの変更(ホストプロセスUI)の他に、プラグイン本体からの変更(インプロセスUI)と、「プラグインUIアプリ」からの変更()純粋なアウトプロセスUI?)についても検討していたので、設計をいろいろ検討…というか再検討…する必要がありました。それで「オーディオプラグインのパラメーター変更と通知に関する覚書」を書いたりもしていました。

MIDI 2.0 UMP仕様書日本語版(未遂)

7月にCOSCUP 2022でMIDI 2.0のセッションをもったわけですが、MIDI 2.0の情報ってまだあまり無いんですよね。わたしは最大の癌はMMAの「ログインしないと仕様書がダウンロードできない」時代遅れの組織であることが原因だと思っていますが、共同使用策定者であるAMEIのサイトに行くとログインなしでダウンロードできます…という話をそのセッションでもしてきました。

それで「そういえば割と長いこと待っているけど日本語仕様書出てこないな…?」「AMEIにはMIDI 2.0の日本語情報ページがあるんだけど半年くらい更新されていないな…?」となっていたのを思い出して、「UMP仕様書とか短いから2, 3日あれば本体(Appendix以外)は訳せるだろうし、適当に作ってみるか」と思って、8月のはじめの頃に3日くらいで結局全部(Appendixも含めて)全部翻訳しました。

翻訳できたはいいのですが、これどうしようかな…勝手に公開というわけにもいかんだろうし、とりあえずAMEIのMIDI規格委員会のページにはこうあるのを見てとりました。

私たちは、共にMIDI関連の楽器やプログラム、ガジェットの開発/研究を行っていただける仲間を求めています。音楽に関わるお仕事をされてている方々、MIDIに興味をお持ちの学生の方で「個人情報の取り扱いについて」に同意いただけましたら、お名前、メールメールアドレスを記入の上、ご意見、ご要望をお送り下さい。尚、コメント欄は空欄でも結構です。MIDI関連のイベント等について情報を発信すると共に、今後の活動に役立ててまいります。

訳文はAMEIの訳語統一方針とか何も知らない状態で作ってあるので(調べようと思えばMIDI 1.0仕様書から推論できるといえばできますが)、とりあえず仮訳とか参考訳とか、もし何も無ければベースに使えなくはないだろうと思ってprivate gistにmarkdownを貼って送りつけてみました。3週間後に返事が返ってきて(夏季休暇の時期だし「定例会があれば議題に取り上げてもらえるだろう」くらいの推測でひと月は完全に反応待ちのつもりだったので、適切に対応してもらえたと理解しています)、翻訳作業そのものがMMAと示し合わせて進んでいるような状態なので静観してほしい、という内容だったので、訳文は非公開のまま成果物が出てくるのを待とうと思います。

MIDI 1.0 to 2.0 Translator in cmidi2 and ktmidi

仕様書を翻訳していて気付いたのですが、UMPのMIDI 1.0 from/to MIDI 2.0の変換を規定するDefault Translationに対応する実装がまだktmidiでもcmidi2でも出来ていなかったので、前述のAAPポートのイベント変換まわりを実装するときにも必要になるなと思って、とりあえず1.0から2.0への変換機能を追加しました。2.0のUMPを1.0のバイトコードに変換する機能は、以前から部分的に実装してあったので、そこはそのままです(sysexだけ複雑になるのでやっていなかった)。cmidi2はsingle header, inline functionのみの実装を想定していたので、この辺までやってしまうとだんだんktmidi並に複雑になってしまうのでやりたくない…と思っているのですが、まあ変換くらいならいいでしょう。

この辺をがっつりやるのであれば、本来ならMIDI 1.0メッセージからMIDI 2.0 UMPへの変換だけでなく、MIDI 1.0 UMPからMIDI 2.0 UMPへの変換もできたほうがよいのでしょうが、MIDI 1.0 UMPのストリームを使う場面がまだ自分のユースケースで出てこないので、後で考えよう…となっています。JUCEだとこの辺をByteStreamInputHandlerU32InputHandlerで分けている感じで、これくらいなら複雑になってもいいか…と思わなくはないですが、なるべくシンプルで直感的なAPIにしたいところではあります。(まあJUCEのUMPのAPIがわかりにくいのは、この場合は過剰な共通化ではなく単なるドキュメント不足だと思いますが。)

9月の予定(のつもり)

パラメーターポートまわりの次世代実装のProof of Concept実装が使えそうだとわかったので、現在はPluginPreview(プラグインのテストプレビュー実装)とそのUI (Activity) まわりを改造しています。現状でもlv2applyの入力を固定したやつみたいな感じですが、もう少しMIDI playerに近いものを作りたいところです。

あと9月には技術書典13があって10月にはM3 2022秋もあるので(出展予定)、新刊を用意しないと…(技術書典は最終日が締切日くらいに思っているところがある) …というわけでコーディングは少なめになるかもしれません。

オーディオプラグインのパラメーター変更と通知に関する覚書

最近自分のプラグインフレームワークプラグインパラメーターのサポートを設計しているのだけど(パラメーターのサポート無かったの!?と思われそうだけど、これまでは古いLV2と同じでパラメーター毎に「ポート」を用意するスタイルで実現していたのを再設計している)、特に変更通知まわりは割と面倒なところがあるなと思ったのでちょっとまとめておきたい。

パラメーター操作の分類

オーディオプラグインのパラメーターを操作する主体は2つ、あるいは3つある。

(1) ホスト(DAW)のシーケンサープラグインprocess()とかrun()とかに相当するオーディオ処理関数が呼び出されたときに、MIDI…に類するイベント…としてパラメーター設定命令が(理想をいえばタイムスタンプ付きでsample accurateに)そのprocess()あるいはrun()のサイクルの分をまとめて渡される。データの元がMIDIマッピングされたMIDIシーケンスであろうと、オートメーションであろうと、APIとしてはこれらの関数に行き着く。これらの関数はMIDI…に類するイベント…のアウトプットも取得でき、ホストはこれを現在処理中のフィルターチェインにある次のプラグインに渡すことができる(これはおそらくは一度MIDIに類する抽象イベントに変換することになる…というのは、たとえば、VST3のチェインの次にAUが繋がっていたらVST3イベントそのものを渡すことはできないからだ)。

(2) プラグインのUI操作。一般的に、GUIはリアルタイムで処理できないので、リアルタイムのオーディオ処理ループからは切り離されていて、したがってスレッドコンテキストも分離しているので、GUI上で生じたパラメーター変更のイベントは、いったんプラグインの内部でキューに溜め込まれて、次のprocess()run()の中で(理想をいえばタイムスタンプ付きでsample accurateに)処理される。ホスト側でプラグインのパラメーターの状態(現在値)を把握しておきたいことを考えると、UIによるパラメーター変更はホスト側に通知されるのが理想的で、したがってプラグインがホストに通知イベントを送信する仕組みが必要になる。

(3) オーディオ処理そのもの。これはパラメーター変更の入力ではなく出力 = 通知の話になるが、パラメーターには他のプラグインパラメーターの変更によって計算される性質のものがあり得る。これらはオーディオ処理に直接影響するとは言い難い(その関連パラメーターの変更は上記のいずれかですでに渡されている)が、これによって生じたパラメーターの再計算は、その変更を通知されるホストにとっては、プラグイン上にに生じたパラメーター変更と同義だ。

ただ、これら(1)〜(3)の処理モデルは、リアルタイムのオーディオ処理ループが稼働していることを前提としている。もしオーディオループが回っていなければ、UIによる変更を反映する次のprocess()run()は回ってこないので、それに伴って生じるべき変更通知はずっとやってこない。オーディオループが回っていない状態でもイベント通知が滞りなく届くようにするには、オーディオ処理が回っていない状態でもパラメーター変更が処理される仕組みが必要だ。そうしないとプラグインGUIで設定したパラメーターの値が、ホストで必要な箇所に反映されなくなってしまう。

UIイベントをプラグインに蓄積させるAPIプラグインにイベントを処理させるAPI

上記のうち(2)に関しては、一般的にはGUIプラグインのプロセスの中で動作しているので、GUIが発生させるイベントは同じプラグインの内部でイベントキューを保持しておいて、オーディオ処理が走るたびに内部で(1)のイベントストリームとマージして処理することになると考えられる。この場合、外部にAPIとして表出させることはない。

一方で、LV2 UIのように、プラグイン規格そのものがGUIをきちんと分離して、外部から入力をデータとして渡す仕組み担っている場合、あるいはその他のプラグインでもUIをきちんと分離する仕組みにしている、あるいはプラグイン本体とプロセス分離せざるを得ない場合(AAPがUIをホスト側で生成するとそうなる)、イベントキューイングのためのAPIプラグイン側から表出している必要がある。LV2 UI拡張のLV2UI_Port_Subscribe LV2UI_Write_Functionはこれを体現したAPIと考えてよいだろう。このAPIはイベントのキューイングのためのAPIであって、イベントを処理するためのAPIではない。

一方で、前述の通り、リアルタイムのオーディオ処理が停止している状態でもパラメーター変更が処理される仕組みが必要で、これはprocess()run()と同様に、イベント出力を生成する必要がある。このAPIの具体例と言えるのがCLAPのparams拡張に含まれるclap_plugin_params_tに含まれるflush()だ。LV2のGUIに変更を反映させるための仕組みとは性格が異なる。(このflush()の位置付けは実のところ曖昧だというissueも立っているが、本質的には書き方の問題であって仕様の設計がぼやけているということではないはずだ。)

別ベクトルの解決策として、そもそもprocess()run()をリアルタイム処理中以外でも呼び出せるようにすれば良いのではないか、そうすればイベント処理の実装はこれらのオーディオ処理関数で一元化できるのではないか、とも考えられるが、それはそれできちんと入力を処理できないプラグイン(すなわちオフラインレンダリングに対応できないプラグイン)が存在する可能性があって、この路線を押し通すには事前に調整されて然るべきということになる(たとえばAAPではまだ仕様が策定中なのでそういう前提を作れる)。

これが多分難しいのは、DSPの処理がリアルタイム前提で時間計算するようになっていたら、オフラインレンダリングには対応できないということと、DSPは特定のプラグインフォーマットから独立して実装されていることが多く、既存のDSPコードがオフラインレンダリングに対応できていない場合、process()run()がオフラインレンダリング対応を前提にしていると、そのDSPそのものが利用不可能になってしまうかもしれない、とかもしれない、といったことが考えられる。

非同期イベントとしての通知出力と、同期イベントとしての通知出力

リアルタイムのオーディオ処理のバックグラウンドで動作するUIを経由したパラメーター変更は、一般的には非同期イベントとなる。これに対して、オーディオ処理を経てのパラメーター変更通知が非同期となるべきかどうかは議論の余地がある。一般的には、UIコードがパラメーター変更のAPIを呼び出しても、前述の通りいったんキューイングされるだけだ。一般的にはキューイングするだけの処理の中で、パラメーター変更通知に値するプラグインの処理が発生するとは考えられず、一般的にはオーディオ処理の「結果」として通知に値するイベントが生成されるはずだ。キューイングの時点で通知イベントが生成されることを期待するようなAPIにしていると、何がしたいのかよくわからないぼやけたAPIになってしまう。

前述のclap_plugin_params_tに含まれるflush()を取り上げると、この関数はリアルタイムオーディオ処理が走っていないときに呼び出されてclap_output_events_tにイベント出力を格納することが想定されており、これは同期的なAPIだ。逆に、LV2 UI拡張にあるLV2UI_Write_Functionport_eventのようなAPIでは、その関数呼び出しから何らかの通知イベントが生成されることは期待されていない非同期的なAPIだ。そもそもGUIからプラグインを経ずにイベントを生成することは推奨されない。

CLAPのparams拡張のような同期的にイベント処理を行う方式については、もうひとつ言及しておくことがある。CLAPの場合、パラメーターの変更はプラグイン側で発生するイベントだが、プラグイン側からホスト側に対してイベント出力を直接送信する仕組みにはなっていない。その代わり、プラグインはホストから事前に渡されているホスト側のコンテキストであるclap_host_params_tのメンバーrequest_flush()を呼び出して、あくまでホスト側からclap_plugin_params_tflush()を呼び出すことを期待している。こうすることで、通常のオーディオ処理とイベント通知による処理のモデル(特にスレッディングモデル)を統一できる。オーディオ処理がリアルタイムで動作している状態であれば、request_flush()を呼び出さなくても、次のサイクルで通知イベントを出力するだけでよい。

パラメーター拡張APIは誰のためのものか (8/31追記)

前回はパラメーター変更と通知のAPIの類型について解説したが、特にLV2とCLAPでの位置付けがだいぶ異なることが見て取れた。なぜそのような違いが生じるのかを、少し踏み込んで考えたい。

一番根本的な違いは、それらのAPIを「誰が」使うのかを明確にすると見えてくるだろう。

  • CLAPの場合、拡張プラグインAPIを呼び出すのは常にホストであり、拡張ホストAPIを呼び出すのは常にプラグインだ(と自分は理解している)。パラメーターのflush()はホストが呼び出すものだし、プラグイン側はプラグインAPIを呼び出すことはない。プラグインGUIコードがプラグインDSPコードで利用するパラメーターを設定するために使用するAPIは存在しない。それはCLAPのスタンスでいえば、プラグイン本体が内部的に解決(提供・実装)すべきものであって、プラグインとホストのインタラクションに影響しないなら規格化されない。オーディオプラグインデザインパターンのようなものは、CLAP全体として標準化されない傾向があるように見える。
  • LV2の場合、LV2 UIの実装はLV2 DSPの実装とは別の共有ライブラリになり、パラメーターの変更と通知は接続されたポート経由で行う。LV2 UIの実装とLV2 DSPの間でインターフェースすなわちAPIが必要になる。

ちなみに、この一連の覚書でCLAPに関してparams拡張の話だけしてgui拡張の話を一切しないのは、CLAPのgui拡張は本当にGUIを扱っているだけで、GUIから発生するであろうパラメーターの変更イベントに関しては一切関知していないからだ。この辺CLAPの切り分けは美しくできている。最初に見たとき「拡張機能が"UI"ではなく"GUI"なのはセンス無いな…」と思ってしまったものだけど、この拡張は本当にGUIのためのものであって、たとえばUIとDSPの分離などを目的としてはいない。

Android 13のMIDI 2.0サポートについて

Android 13正式版がリリースされてAOSPにcode dropされたようです。

Android 13には興味深い新機能がいくつかあって、個人的にはLE Audioサポートの追加で音楽ソフトの体験が変わりそうなのとAndroid 12Lで追加された(主に)タブレット向けの複数Activity並列動作のサポートが気になるところですが、それらと並んでMIDI 2.0サポートが追加されたことがAndroid Developers Blogに書かれているので、これを少し解説します。

MIDI 2.0 - Android 13 adds support for the new MIDI 2.0 standard, including the ability to connect MIDI 2.0 hardware through USB. This updated standard offers features such as increased resolution for controllers, better support for non-Western intonation, and more expressive performance using per-note controllers. More here.

MIDI 2.0プロトコルサポートの概要

MIDI 2.0サポートに対応するAPIとしては、android.media.midi.MidiDeviceInfoに追加されたgetDefaultProtocol())メソッドのように、新しいMIDIプロトコル定数を利用するものがいくつか増えた程度で、Android 10で追加されたNative MIDI APIのような目新しさはない(小さい)です。

Android 13でMIDI 2.0サポートが追加されたのはUSB MIDI 2.0デバイスのサポートのみです。しかも「USBプロトコルでネイティブにMIDI 2.0サポートに対応したもの」だけが新規にサポートされたことになります。

MIDI 2.0はMIDI 1.0との後方互換性を強く意識して設計されています。その成果のひとつとして、MIDI 2.0のメッセージをあらわすUMPのパケットは、MIDI-CIとMIDI 2.0 Protocol Negotiationを利用することで、MIDI 1.0な接続環境でも送受信できるように接続を調整できます(これを実現するためには、仕様上は、送受信の双方向接続が確立される必要があります)。5-PINのMIDIケーブルでの接続まではカバーできないような話がMIDI 2.0仕様書には書かれていますが、USBやソフトウェアによる仮想MIDIバイスに関してはMIDI 1.0の接続さえあれば、そこにMIDI 2.0 UMPを流し込めるというわけです。

Android 13にはMIDI 2.0 UMPを「扱う・処理する」APIは存在しません。そもそもMIDI 1.0を扱う・処理するAPIAndroidには存在しません。MIDIメッセージもUMPストリームもただのバイト配列であり、こういうのはアプリケーションで趣味に合わせて都合の良いものを使えばよく、プラットフォームAPIとして存在する必要はありません。筆者もそういう意識でMIDI 2.0 UMPを扱うライブラリktmidiを作ってGitHubで公開しています。C++ならJUCEのUMPサポートを使うのもいいでしょう。

100%手前味噌なのですが、この辺は先月COSCUP 2022でも話してきた話であり、また1年ちょっと前に発行した同人誌「MIDI 2.0エコシステム構築術」でもまとめています。

xamaritans.booth.pm

ネイティブMIDI 2.0プロトコルバイスのサポート

今回Android 13でサポートされたのは、このMIDI-CIとProtocol NegotiationによるMIDI 2.0プロトコルへの昇格ではありません。これはAndroid 12以前でも利用できたものである、と考えてよいのです。実際、筆者は上記ktmidiプロジェクトの一環として作成した仮想MIDIキーボードアプリkmmkで、MIDI 2.0プロトコルに昇格して…した気分になって(Protocol Negotiationの応答処理をすっ飛ばして)…UMPを送りつけるモードを実装して、別途Android用のオーディオプラグインフレームワーク(自作)から作り出した仮想MIDIデバイスに実際に送りつけて音を出すところまで作ってみたことがあります(今はちょっとまともに動作しない状態になっているので言及するのもちょっとアレですが…)。

Android 13でサポートされるようになったのは、MIDI 2.0プロトコルネイティブな接続です。MIDI 1.0の頃は、MIDIプロトコルMIDI 1.0によるものだけでした。MIDI 2.0の時代になって、このUMPのみを送受信するMIDIバイスという概念が新たに発生することになりました。すなわち、MIDI-CIによるプロトコル昇格を経るまでもなく、最初からUMPでやり取りするデバイスがあり得る、ということです。

あるUSB-MIDIバイスが最初からMIDI 2.0プロトコルしかサポートしていないかどうかは、USB-MIDI 2.0デバイスであるかどうかをUSBプロトコルに則って得られる情報から判断できます。そしてその情報が、冒頭で言及したAndroid 13の新しいAPIであるMidiDeviceInfo.getDefaultProtocol()などで取得できるというわけです。

もうひとつ、MIDIアプリケーション開発者にとって重要な機能としては、MidiManager.getDevicesForTransport())というメソッドを使うと、MIDI 1.0あるいはMIDI 2.0専用のデバイスに限定してリストを返すことができます。

なお、android.media.midiのAPIリファレンスには、USB-MIDI 2.0をサポートするデバイスでも、MIDI 1.0プロトコルによる接続もサポートするべきである、と書かれています("A MIDI 2.0 USB device should create two interfaces, one endpoint that accepts only MIDI 1.0 packets and one that accepts only UMP packets.")。

また、Android 13のAPIで追加された定数は、MIDI 2.0プロトコルのいくつかのオプション(たとえばJRタイムスタンプを伴うか否か等)に関連するものですが、これらについてはMIDI 2.0 UMP仕様書の"3. MIDI Protocls in UMP format"などを参照してください。

MIDI 2.0ネイティブのサポートはUSB-MIDI 2.0デバイスのみ

この意味では、Android 13に追加されたMIDI 2.0サポートはUSB-MIDIに限定されたものであるように見えますが、実のところそのとおりになっています。USB-MIDIMIDI 2.0プロトコルネイティブなものを作成できるようになっているのであれば、仮想MIDIバイスでもそうなっていてほしいと考えるのは自然なことだと思いますが(!)、現状ではMIDI 2.0ネイティブなMidiDeviceServiceは作成できません

MidiDeviceInfoクラス経由でわれわれMIDIアプリケーション開発者が取得できる情報は、別のわれわれMIDIバイス開発者がdevice_info.xmlなどの「デバイスマニフェスト」で公開できる情報に含めることができる情報であり、ここに「MIDI 2.0プロトコルネイティブだよ」と記述すればOKとなってほしいところです。そう考えてGoogleissuetrackerにfeature requestを出したのですが、先送りになってしまいました。

理由はこのissueに書かれているのですが、もしデバイスマニフェストプロトコル情報を追加するだけで対応してしまうと、MIDI 2.0プロトコルネイティブのMidiDeviceServiceはMIDI 1.0と後方互換性が無いにもかかわらず、MIDI 1.0デバイスに接続するアプリケーションからも接続可能なデバイスとして列挙されてしまいます。これではユーザーがちゃんと使えるデバイスを利用できないことになるので、恐らく別のIntent Actionが必要になることでしょう。

そもそもUSB-MIDI 2.0対応デバイスは現状ほぼ存在しない

前述のCOSCUP 2022のセッションでも話した(スライドにも書いた)のですが、2022年8月の時点で、MIDI 2.0に対応したMIDIキーボード等のMIDIバイスはほぼ何も存在しません。開発用の基板のようなものを作って売っている会社はあるようだ、というレベルです。MIDI 1.0インターフェース上でMIDI-CIのプロトコル昇格を利用してUMPをサポートするほうが現実的なので、MIDI 2.0ネイティブのデバイスというものがリリースされ、サポートされないと困る…!とまで言えるようになるのは、近くない将来のことになるでしょう。

そういうわけで、Android 13のMIDI 2.0対応は、現状ではほぼ無視してよいレベルであるといえます(断言)。一方で、USB-MIDI 2.0仕様はもう確定していて変わらないので、Android 13の現時点でUSB-MIDI 2.0に対応できているというのは良いことだといえます。MIDI 2.0ネイティブデバイスの普及期にはAndroid 13が普及機で動作しているというのが理想的な状態です。

MIDI 2.0サポートに向けてとるべき対応

われわれMidiDeviceServiceの開発者がMIDI 2.0に対応するには、ソフトウェアレベルでMIDI-CIのプロトコル昇格に対応してUMPを処理できるようにすることです。これをしっかり実装しようと思うと、それなりのMIDI-CI基盤を構築しなければならず、これは少し骨の折れる作業になるので(送受信のcorrelationが必要になる)、Androidオーディオチームではこの目的でみんなが使えるMIDI-CI実装を開発して公開するつもりであるようです…と先のissueでコメントされています。コメントしているのはAndroidオーディオチームのリーダー(Oboeの開発者でもある)なので、それなりの確度で出てくることでしょう。

MIDI 2.0をサポートするDAWなどのアプリケーションもまだまだAndroidでは存在していないので(完全にchicken and eggの問題)、われわれが「MIDI 2.0をサポートしてほしい」という機能要望を見るようになるまでにはしばらくかかることでしょう。その間にAndroidオーディオチームがMIDI-CI対応ライブラリやAPIを出してきたら、そこから対応を始めるくらいでも十分ではなかろうかと思います(個人的には自分でMIDI-CI対応機能を作ってしまうと思いますが)。

7月の開発記録 (2022)

COSCUP 2022に登壇するために台湾に来ています。

台湾はまだ観光ビザで訪問できないので、就業金卡というビジネスビザの一種で来ています。ある種の職能スペシャリストビザの一種で、いくつかの条件を満たせば申請が受理される見込みがまあまああるのですが、キャリア年数などのほか年収基準なども150万NTDくらいで、IT技術者(条件のひとつを満たす職種)ならそれなりの収入がある人も少なくないと思います…と書くつもりだったのですが、昨年自分が申請した時に比べて日本円の価値が暴落したので、割と厳しくなったと思います…

周りの外国人の知人は胡散臭い連中だらけだったのに割と漏れなく取得できていて、しかも3ヶ月くらいで取得できていたので、自分も…と思っていたら、承認が降りたのは1年前だったのにその頃にオリンピックのせいで感染爆発が起こって、手続窓口が今年の春先までずっと閉じていて、必要な手続は今頃になって進められたという…いま申請すればたぶん3ヶ月くらいでいけると思うので興味のある人は試してみましょう(滞在可能年数に応じた申請手数料がかかります)。

台湾は三+四天(天は日数)という入国者隔離政策があって、過去に比べるとずいぶん緩められていて、たとえば自宅隔離が許されていたり隔離タクシーでなくても家族が車で迎えに来たりできるので、わたしなんかは失策だと思うわけですが、今のところBA.5もほぼ阻止できているようです。以前に行き来していたときも事前準備していると割とナーバスになったものですが今回はさらに輪をかけていろいろ気にしながら進めました。特に就業金卡は日本では情報が少ないですし(わたしがもっと書けって話になりそうですが)、日本から出国する日本人のための情報は英語圏でも見つからないですしね…

オーディオプラグイン規格勉強会 (2022.7)

先月も書いたのですが、CLAPという新しい規格…いやホントは昔からあったのですが…が正式リリースされて脚光をあびるようになったので、CLAPの勉強会を開催…する前にそもそもオーディオプラグイン規格全般を調べながらみんなでわいわい討論できる勉強会をやりたい…ということで7/6に開催しました。これについては今月4回にわたってここに連載したものがあるので、それを見てもらえればと思います。これを勉強会のために下調べとして書いていたので(そうするとスライドもまとめやすいわけです)、それでそれなりの時間を使いました(といってもほとんど6月の話です)。

What for, Where and How to Adopt MIDI 2.0 @ COSCUP 2022

7/30に台湾の巨大OSSカンファレンスCOSCUP 2022にて表題のタイトルで「MIDI 2.0の技術はどこで応用できるのか」という話をしました。

coscup.org

MIDI 2.0は今使われているわけでもないし、デバイスもなければOSサポートも無く、MIDI 2.0が実現するのもオーディオプラグインの既存の技術で実現できていることが中心だけど、MIDI-CIを使ってMIDI 1.0のトランスポートの上に成り立つもので、オーディオプラグインがバラバラに実現してきたものがMIDI 2.0によってふたたび標準としてDAWの内部で音楽表現を担う可能性があるものとしての未来を見出してほしい、という内容です。スライドはspeakerdeckで公開してあります。

speakerdeck.com

申込み時に「まあ初歩的な話しか出来んやろ」と思って入門レベルとしていたので、特にコードもデモもなく表層的な話で終わっています。ただもしかしたら開発初心者ではなく打ち込み初心者が来ていた可能性がry(まあ「MPEとか使えばMIDI 1.0ベースの世界でもper-note pitchbendとかできるよ」みたいな話もしたので大丈夫なはず…)

本当はAndroidのオーディオプラグインにもMIDI 2.0サポートを組み込んであるよホラ見て、みたいな話をしたかったのですが、実装のバグつぶしに負われていて「無理はせんとこ…」ってなりました() こっちがうまく動いてもMIDI 2.0クライアント(ktmidi/kmmk)と協調できないと困るし…AAP本体もそうなのですがC/S系のコードになるので面倒くさいところがあります。デスクトップならまだ楽なんですが。

AAPクライアントの再構築

AAPの拡張機能を実装してから、AIDLベースのKotlinクライアント実装ではそれに対応できないのを全面的に再構築する必要があったわけで(先月の続き)、7月はそれを片付けました。

今主に進めているのは新しいポート設計の部分で、現状LV2のTTLのようにメタデータXMLに全部列挙しないといけなくなっているものを、プラグインのコード主体で設定できるようにするものです。LV2 dynamic manifestのように動的にポートを設定したいというよりは、(1)オーディオバスを柔軟に設定できたり、(2)MIDI-CI実現のためのポート動的生成を可能にしたり、(3)オーディオポートのメタデータ記述を不要にしたい(デフォルトでステレオ設定としたい)といった目的です。特に(2)でMIDI IN/OUTを自動生成できるようになると、拡張機能の実装にsysexを使えるようになるので、そこに期待しています。

これを実現するためには、KotlinクライアントのAPIもネイティブAPIメタデータではなくインスタンスからポートを取得する必要があり、そのためにはJetpack Composeで実装していたプラグインプレビューのアプリもメタデータではなくインスタンスを中心に構築する必要があり…と、いろいろ芋づる式に書き換えることになりました。これは不安定なクライアント(ホスティングAPIなので、まだ表に見えるメリットのある変更にはなっていない感じです。プレビュー機能もまあまあイマイチなので(ホントにテスト用でしかない)、もうちょっとまともな機能と実装がほしいところではあります。ただ、さすがに優先順位が低い…それとは別に、この辺の内部実装をいろいろ書き換えたことでaap-juceなどもさまざまな問題が表出するようになって、バグフィックスばかりやっています。もうちょっと楽しい機能追加やりたいなあ…

あと最近はAndroid Studioあるいはlldbまわりが不安定でデバッガが何も進められなくなったりとか、NDKのバージョン次第でscudoがエラーを返すようになったり(しかも意味のあるメッセージは何も出さない)とか、いろいろツールチェインの問題だらけで開発体験が非常に悪くなっているので、どうにかなってほしいところです。まあどうにか「する」にはオープンソースでないと無理なわけですが、オープンな部分でもまだビルドの全体像を把握しているわけでもないので、気が向いたら掘り下げようと思います(向かなそう)。

CLAP 1.0公開を機にオーディオプラグイン規格の何たるかを知る (4) 拡張機能・各論 ほか

一般的な拡張機能

状態(state)の保存・復元

DAWで音楽を打ち込んだ内容をセーブすると、各トラックに設定されたプラグインのパラメーター等をひと通り操作した状態(state)を保存することになります。逆に楽曲データをロードすると、プラグインのパラメーター等を保存データから復元することになります。

DAWはひとつのプロセスの中にさまざまなプラグインをロードするので、他のアプリケーションに比べると頻繁にクラッシュします。そのため、楽曲をセーブする前に予防的に状態を保存することがあります。

状態の保存先は大抵のプラグインフォーマットではただのバイトストリームですが、LV2では保存する項目と値を構造的に格納するスタイルの仕様になっています。LV2は全体的にSemantic Webの流儀に従っており、LV2 Stateの仕様もその影響を受けていると考えられます。

プリセットの利用

プラグインの中にはMIDIでいうところの「プログラムチェンジ」に相当する機能を実装しているものがあります。シンセの場合は、これは単純にパラメーターの設定値の集合だけで実現していることも多く、その場合はプリセットと呼ぶほうが適切ともいえます。この機能をどう呼ぶかはプラグインフォーマット次第です。ここでは便宜上プリセットと呼びます。JUCE AudioProcessorならProgramと呼ばれます。

パラメーター設定の集合であると考えるとピンとくるかもしれませんが、プリセットが実装すべき機能は実のところ状態の機能とほぼ重複します。プリセットのロードとは、プログラムナンバー、プリセットの番号といったものを状態の代名として指定して状態を復元するのとほぼ同じです。

プラグインフォーマットによっては、ユーザープリセットの保存のような機能を可能にすることも考えられます(JUCEにはそのような機能が存在します。AudioProcessor::ChangeProgramName()など)。

GUI

GUIはオーディオプラグインの重要な機能のひとつですが、「無くても良い」機能でもあります。GUIが無い場合でも、外部のエディターからパラメーターを操作できる「エディットコントローラー」(これはVST3の用語です)の機能があれば、DAWプラグインのパラメーター方法をもとに自前でパラメーター操作のUIを用意できるためです。とはいえ、それでもプラグインはユーザーが操作しやすいUIを提供するのが一般的です。

プラグインフォーマットで専用のGUIフレームワークを提供することは多くありません。汎用プラグインフォーマットでは皆無に近いでしょう。プラグインフォーマットで専用のGUIフレームワークを提供するということは、GUIはその仕組みの上に則って構築するということになります。しかし、一般的にはDAWが利用する言語・開発環境は決め打ちにできないので、プラグインGUIはそのGUIと密結合できません。GUIフレームワークを提供しないプラグインフォーマットにできることは、せいぜいGUI操作においてホストとなるDAWとその小窓で出現するプラグインの間で生じる(その必要がある)インタラクションを、呼び出しや通知コールバックのかたちで定義するくらいです。

GUIフレームワークを開発するというのは大規模な作業になりうるもので、実際大規模な作業を経ずに基本機能だけで済ませたGUIフレームワークではさまざまな問題が噴出します。日本人向けにわかりやすい例を挙げれば、日本語入力にまともに対応できないことが多いです。アクセシビリティ対応、HiDPI対応、マルチプラットフォーム対応など、さまざまな難題があるのです。SteinbergはVSTGUIというオーディオプラグイン向けの汎用フレームワーク(そう、VST専用ではないのです)を作りましたが、やはりデスクトップ向けの一般的なGUIフレームワークと比べたらさまざまな点で妥協の産物です(たとえば2022年現在でもCairoが使われていたり)。

プラグインUI開発に最適な銀の弾丸は存在せず、プラグイン開発者は、自分のプラグインの最適解に近い任意のGUIフレームワークを利用する、という以上の一般化はできないといえます。

オーディオプラグインのオーディオ処理はリアルタイムで完了する必要があります。このリアルタイムとは「必ず一定の時間以内に完了する」というものであり、よくhard realtimeともいわれるものです。一方でGUI処理には一般的に「UIスレッドで動作しなければならない」という制約があります。必然的に、オーディオ処理とGUI処理は別々のスレッドで動作することになります。

さて、一般的にプラグインのオーディオ処理とGUIは別々のスレッドで別々の処理ループによって動作することになりますが、プラグインフォーマットによってはGUIの分離がスレッドの分離より一層強く設計されていることがあります。LV2はこの意味では分離アプローチの最右翼で、UIのためのライブラリを別ファイル上で実装して、オーディオ処理部分とはコードを共有できないようにしています。オーディオ処理のコードを参照しなくても、TTLメタデータの情報をもとにUIを実装することが可能であるためです。もちろんそうはいっても、UIのライブラリを参照してそのAPIを利用するコードを書くのを妨げることはできません。

GUIサポートをクロスプラットフォームで一般化するのは、可能ではありますが、技術的にいくつかのアプローチがあり、これがまた一つ難しい要因です。プラグインフォーマットとして何か1つを規定しないわけにはいきません。

  • VST3ではプラグインIEditController::createView()からIPlugViewというインターフェースの実体としてプラットフォーム別のViewを生成して、それをホストに返します。ホストはGUIのViewを(一般的には)自前のウィンドウにreparentして使うことになります。
  • CLAPではホストがclap_plugin_gui_t.create()を呼び出すとプラグインが内部的にGUIを生成しますが、結果はboolでしか帰ってきません。それをホスト側のGUIに統合するには、reparentするウィンドウのハンドルをclap_plugin_gui_t.set_parent()で渡す必要があります。あるいはfloating windowとして扱うという選択肢もありますが、プラグインがサポートしていなければこれは利用できません。clap-juce-extensionsで(つまりJUCEで)構築したプラグインだとfloatingには対応していません。

CLAPでは、LV2のようなUIとDSPのコード分離ポリシーをAPIとして強制してはいません。これは意図的な設計であるとコミュニティでは説明されています。コードをどのように分離するかは各アプリケーションのアーキテクチャ次第ともいえます。

ホストから提供される「楽曲の」情報

オーディオプラグインは基本的にオーディオ処理関数(CLAPのprocess()関数など)に渡されるオーディオ入力やイベント入力をもとにオーディオ・イベント出力を出力するリアルタイムな処理であり、渡される時間情報は基本的にSMPTEに基づく時間(マイクロ秒など)の即値あるいはそれを変換したサンプル数となります。そこにテンポや拍子(time signature)に関する情報は一般的には不要ですが、プラグインによっては、テンポ等の値をもとに生成する音声やMIDIイベントを調整したいことがありえます。これを実現するためには、DAWからの情報提供機能が不可欠です。この情報はトランスポートとかプレイバックと呼ばれることがあります。各プラグインフォーマットでは、それぞれ次に示す型で実現しています。

  • VST3: ProcessContext
  • LV2: Time拡張機能
  • CLAP: clap_event_transport (events.h)

clap_event_transportは拡張ではなくオーディオ処理で渡されるイベントの種類で、トランスポート情報にアップデートがあったときにホストから渡されます。現在の小節位置なども含まれる = 更新の必要が頻繁に生じるので、このイベントをサポートするDAWからはprocess()で送られるclap_process_tin_eventsに含まれることが多いと考えて良いでしょう。

CLAPにはtrack-infoというトラック情報を取得できるAPIもありますが、これはDAW上の表示色など、だいぶ性質の異なる情報を取得するためのものです。

CLAPのユニークな拡張機能

複雑なノートイベント

CLAPのイベント機構は、イベントの種類をCLAPイベント、MIDIイベント、MIDI2イベントから選択できます(他のイベント体系も規定できますが、ホストとプラグインの両方が合意するものを規定する必要があります)。CLAPイベントには、ノート関連イベント、パラメーター関連イベント、トランスポート情報更新イベントなどがあります。パラメーターについては次の節で説明するとして、ノート関連イベントもCLAPに固有のものがあるので説明します。

CLAPのノート関連イベントは次の4つです:

  • CLAP_EVENT_NOTE_ON: ホストからのノートオン指示
  • CLAP_EVENT_NOTE_OFF: ホストからのノートオフ指示
  • CLAP_EVENT_NOTE_CHOKE: ホストからのノート即時停止指示
  • CLAP_EVENT_NOTE_END: プラグインからのノート完了通知

ノートオンとノートオフはMIDIのものと同様の機能です。MIDI 2.0のノートメッセージとは異なり、アーティキュレーションの指定はできません。

。CHOKEとENDは説明が必要でしょう。まず「チョーク」ですが、これはノートを即座に停止する用途で追加されたイベントです。通常、ノートオフとは「リリース」の開始を意味するものであり、すなわちまだ無音になるまではしばらく時間がかかることを意味します。これに対してチョークが指示されると、そのノートの発音は即座に終了します。これが具体的に用いられる例として、CLAP仕様では(1)ドラムマシンのハイハットなど排他的に発音するノートや(2)MIDIにおけるall notes offのような命令として利用することを想定しています。MIDI 2.0であればノートオフのアーティキュレーションに「これはチョークである」という情報を追加することができたでしょうが、CLAPにはそのようなフィールドが存在しないので、こういう命令を追加するしかなくなります。

「エンド」のほうは、ホストから指示する命令ではなく、プラグイン側からホストへの通知で、リリース処理なども終わって完全にノートが無音になったときに送られます。ホストは、もしこれを受け取ってプラグインが何の音も生成していないことが分かったら、プラグインの処理をスキップするなどの最適化が可能になります。これはtail-length拡張によって実現できていた機能と、目的が類似しています。

パラメーター設定関連イベント

CLAPのパラメーター設定イベントもある程度バリエーションがあります:

  • CLAP_EVENT_PARAM_VALUE: 単純なパラメーターの設定
  • CLAP_EVENT_PARAM_MOD: パラメーターのモジュレーション操作(変化率を指定): 開発チームが "non-destructive automation" と呼んでいるもので、モジュレーションが完了したらパラメーターの値を元に戻せる(オートメーションをかけ終わった後に当初のパラメーター設定がなくならない)ことになります
  • CLAP_EVENT_PARAM_GESTURE_BEGIN, CLAP_EVENT_PARAM_GESTURE_END: ユーザーがDAW上のツマミなどでパラメーター操作を開始したことをプラグインに通知するイベント: この間に呼び出されたパラメーター変更イベントは履歴の記録などで厳密にトラッキングする必要がない、と考えられます

モジュレーションとジェスチャーは、表現力を高めるためのものではなく、DAWを利用するときのUXを改善するためのものといえます。(他の規格にも同様の機能を実現するものがあるかもしれません。)

また、パラメーターではありませんが、ノートエクスプレッションもCLAP_EVENT_NOTE_EXPRESSIONで設定できます。対象パラメーターの代わりに以下のいずれかを「エクスプレッションID」として指定します:

enum {  
  // with 0 < x <= 4, plain = 20 * log(x)  
  CLAP_NOTE_EXPRESSION_VOLUME,  
  // pan, 0 left, 0.5 center, 1 right  
  CLAP_NOTE_EXPRESSION_PAN,  
  // relative tuning in semitone, from -120 to +120  
  CLAP_NOTE_EXPRESSION_TUNING,    
  // 0..1  
  CLAP_NOTE_EXPRESSION_VIBRATO,  
  CLAP_NOTE_EXPRESSION_EXPRESSION,  
  CLAP_NOTE_EXPRESSION_BRIGHTNESS,  
  CLAP_NOTE_EXPRESSION_PRESSURE,  
};

ボイス(発音)数の管理

まだ1.0正式仕様には含まれていませんが、CLAPにはプラグインの発音数を管理できるvoice-infoという拡張機能があります。これが使えると、ホストでプラグインの現在の発音総数や最大発音数を取得できます。といっても。出来ることで音声処理に影響があるとは考えられません(雰囲気でパフォーマンスのある種の指標を得られるといったところでしょうか)。

enum {  
CLAP_VOICE_INFO_SUPPORTS_OVERLAPPING_NOTES = 1 << 0,  
};    
typedef struct clap_voice_info {  
  uint32_t voice_count;  
  uint32_t voice_capacity;    
  uint64_t flags;
} clap_voice_info_t;
typedef struct clap_plugin_voice_info {  
  bool (*get)(const clap_plugin_t *plugin, clap_voice_info_t *info);  
} clap_plugin_voice_info_t;

リアルタイム並列処理の制御 (thread_pool拡張)

u-heで頻繁に主張しているCLAPのアドバンテージのひとつが「ホストによって制御されるスレッドプール」です。これについて筆者は「スレッドプールはLV2 Workerなどでも実装されているし、さすがにそれはおかしいんじゃないか」と思ってだいぶコミュニティで掘り下げて議論して分かったのですが、結論からいえば一般的な意味でのスレッドプールでは全くありませんプラグインが非同期実行を実現するための仕組みではありません。

では何なのかというと、CLAPのthread_pool拡張のAPIは、リアルタイム処理を並列で実行するためのAPIです。プラグインがオーディオスレッドで動作しているprocess()の中からホストの機能を呼び出すかたちで利用します。次のような流れになります:

  • プラグインclap_host_thread_pool_t型のホスト拡張をclap_host_t.get_extension()で取得し、これがnullptrなら並列処理ではなく逐次処理を行う
  • clap_host_thread_pool_tを取得できたら、プラグインは続けてrequest_exec(host, numTasks)メンバーを呼び出す
  • ホストのrequest_exec(host, numTasks)の実装では、もし現在そのホストが指定されたnumTasks本のタスクをOpenMPなどの並列実行機構を用いた並列化を試みる
    • できないようなら、それ以上は何も実行せずにfalseを返す
    • 並列化できるようなら、そのプラグインclap_plugin_thread_pool_t型の拡張機能clap_plugin_t.get_extension()で取得する。これがnullptrならfalseを返す
    • clap_plugin_thread_pool_tを取得できたら、ホストは続けてそのexec(plugin, task_index)numTasks回呼び出し、request_exec()の戻り値としてtrueを返す

exec()で呼び出されるプラグインのタスクは、process()のサイクルで完了しなければならないものなので、並列であれ逐次であれ、処理全体をリアルタイムで実行完了しなければなりません。

CLAPのthread_pool拡張とは、こういった機能を実現するためのものです。一般的な意味でのスレッドプールのAPIはありません。一般的なスレッドプールのAPIであれば、タスク/ジョブのオブジェクトを生成してハンドルを渡すようなAPIになっていないと意味を為さないところですが、CLAPの場合はnumTasksという並列実行スロットの本数を渡すのみで、プラグイン側のタスクの呼び出しも同期的です。「thread poolという名前がおかしい」というのは概ねコミュニティにおける共通理解だと思ってよさそうです。

tuning

tuningはmicrotonal(微分音)を実現するための拡張機能です。この機能がオーディオプラグインフォーマットの一部として規定されるのは珍しいといえるでしょう。一般的に、これが拡張機能として規定されないのは、MIDI 1.0に基づくMMAの仕様としてMTS (MIDI Tuning Standards)というものがあって、DAWはこれに沿ってMIDIメッセージを送信し、プラグインはこれを受け取ったらその内容に応じた周波数変換テーブルを適用すれば良いので、独自にイベントを規定する必要がなかったためです。

CLAPの場合、MIDIイベントではなくCLAPイベントで全てを処理するユースケースに対応することを考えると、MTSに相当するメッセージを規定する必要があるといえるでしょう。tuning.hにはclap_event_tuningというMTS相当のイベントが規定されています。

関連情報

あんまし宣伝エントリにしたくないのですが、多分CLAPの位置付け等を理解するうえでそれなりに参考になると思うので並べておきます。

DAWシーケンサーエンジンを支える技術(第2版)」では、DAWがどうやってプラグインを利用するのか、オーディオプラグインはざっくりどういう仕組みとして作られているのか、楽曲のシーケンスはどう作られて保存されているのか、といった話をふわっと書いています(「ふわっと」というのは主観的な表現ですが、コードが出てこない程度の抽象論に終始する内容です)。

https://xamaritans.booth.pm/items/2397203

「LV2オーディオプラグイン開発者ガイド」は日本語でまとまった情報が多くないLV2オーディオプラグインについて解説し、プラグインの開発方法を説明しています。プラグイン開発に携わっていないと難しく、プラグイン開発をやっていると物足りないレベルかもしれません。

https://xamaritans.booth.pm/items/2394242

最後になりましたが、7/6に開催した勉強会のスライドを公開しておきます。内容はこの連載と合わせて作成していたため、かぶる項目が多いと思います。

speakerdeck.com

CLAP 1.0公開を機にオーディオプラグイン規格の何たるかを知る (3)オーディオ処理のパイプライン

オーディオ処理部分の基本形

オーディオプラグイン規格は、それぞれ互換性が無いものですが、楽器やエフェクターとしての基本的なオーディオ処理の部分には共通の部分が多いです。以下に擬似コードで列挙します。

MIDI入力」と書いている部分は、実際にはMIDIではなくプラグイン規格ごとに異なりますが、これについては次の節でじっくり説明します。ここではオーディオ入力について掘り下げます。

オーディオデータとバス、ポート、チャンネル

「オーディオデータ」に渡ってくるのは、「全オーディオチャンネル」分のfloatあるいはdoubleの配列です。最近はdoubleを使う状況もありますが、一般的にはfloatが使われるでしょう。「全オーディオチャンネル」とは、1つ以上の「オーディオバス」ごとに割り当てられた「チャンネル数」です。オーディオバスは少しややこしい概念で、難しければ飛ばしても何とかなる概念です(飛ばした場合は「ステレオ」になると考えれば良いです)。もう少しちゃんと説明すると、オーディオバスとはチャンネルの構成に名前と特性がいくつか付いた構造です。具体的なものを挙げたほうがわかりやすいでしょう:

  • mono
  • stereo
  • 5.1ch
  • 7.1ch
  • ambisonic

これらの名前は暗黙的に「メイン入出力」を含意していることが多いです。これらはそれぞれ固有のチャンネルを有しており、一般的にはチャンネルにも名前が付いています("L", "R", "front left", "rear back right", ...)

「メイン」があるということは補助的なものもある…というわけで、オーディオプラグインには「サイドチェイン」として直接再生するわけではなく波形の計算に補助的に利用するオーディオデータを渡すこともできます。LV2ではCVPortがサイドチェインを実現するためのものです(CVにはLV2のどんなポートを使っても特に問題はなく、あくまで指針としてCVPortを使うのが適切ということです)。

ホストDAWは、各トラックについて、オーディオプラグインに「利用できるオーディオバスの情報」を提示してもらい、トラックで利用できるバスを調整し、バスのオーディオバッファを準備します。メインのバスは一般的には1つになりますが、補助的にサイドチェインのバスが複数有効化される可能性があります。そして実際にオーディオ処理process()を呼び出すとき、有効なオーディオバス全てに関連付けられたオーディオチャンネル分のバッファが、引数として渡されることになります。

VSTの場合はバスと呼ばれますが、CLAPではポートと呼ばれています。LV2でもポートと呼ばれるのですが、これはバスに相当する概念ではなくチャンネルに相当する概念なので(つまり「ステレオ」ポートにはならず「左」ポートと「右」ポートになる)、混乱しないように区別して捉えておかなければなりません。

オーディオプラグインにおけるMIDIサポートの基本

CLAP開発チームがさかんに宣伝している機能のひとつが高度なノート命令ですが、これを理解するためには、そもそもオーディオプラグインはどうやってMIDI命令を処理しているのか、理解しておく必要があるでしょう。

実のところ、オーディオプラグインでなまのMIDIメッセージをそのまま処理することはあまりありません。オーディオプラグインフォーマットごとにMIDIより高度な(データ幅や追加情報が多い)ノートイベントやコントロールチェンジイベントなどが規定されていて、MIDI入力はDAWによって変換されてプラグインに渡されるのが一般的です。プラグインを開発できるSDKによっては、受け取ったイベントメッセージをMIDIメッセージに(DAWとは逆の方向に)変換して、VSTAUやLV2の共通コードとして実装できるようにすることも多いです(JUCEなど)。

オーディオプラグインMIDIのようなイベントを受け取る入口は主に2つあります:

  • イベントとして受信する: DAWでPCに接続されたMIDIキーボードの鍵盤を押すと今のトラックのオーディオプラグイン設定で非リアルタイムに演奏されます(発音します)
  • 演奏命令として受け取る: DAWに打ち込んだ内容を再生するとき、DAWは対象の全トラックでリアルタイムオーディオスレッドを用いて「オーディオループ処理」を回します。1回の処理はリアルタイムと言える間隔(10ミリ秒程度)の間に全て完了しなければなりません。そうしないと不自然な「空き」が生じてしまい、遅延やノイズの原因になります。このループの中に、演奏命令としてイベント列も含まれることになります。

ここで一つ気をつけないといけないのは、オーディオループはリアルタイムスレッドで回っていて、一方でMIDIキーボード等の入力はI/Oを伴う非リアルタイムスレッドから受け取るということです。リアルタイムでオーディオ処理を回しているときに、ちょっとだけ時間を借りてMIDI入力に対応する音声を生成して戻ってくる…ということはできません。

実際にDAWを使っているときは、トラックを再生しながら同時にMIDIキーボードを叩いていることがあり、この意味では受信した入力イベントは演奏命令にマージされると考えて良いでしょう。ただしそのタイミングは1回のオーディオループで処理される実際の演奏時間の長さによって変わります。もし仮に1回のオーディオループで1000ミリ秒分のオーディオデータが処理されるとしたら、オーディオループは1秒に1回しか回りません。その間のどのタイミングでMIDIキーボードの鍵盤が押されたとしても、ノートイベントが発生するのはその1秒単位のスライスの始点でのみということになります。

DSPコードの共通化

VSTAUもCLAPもそれぞれバラバラなパラメーターとイベントの機構をもっていますが、VSTでもAUでもLV2など他のフォーマットでもプラグインをリリースしたいと思ったら、それぞれのフォーマットに固有のDSP処理を書くよりも、MIDIのような共通の音楽演奏命令のデータを使って記述するようにしたほうが、再利用性が高いです。

DSPでコードを共通化できるということになったら、JUCEのようなクロスプラットフォーム・マルチプラグインフォーマット用フレームワークのほか、FAUSTやSOUL(SOULの権利はROLI社に残ってしまったので創始者のjulesは今は新しくc-majorという言語を作っているようですが)といったオーディオ処理に特化した言語、あるいはMATLABのような言語環境も適用できる可能性が高くなります。

例として、オーディオプラグインフォーマットのようなものが登場すると、MDAという定番DSPモジュールの集合体がmda-vst, mda-vst3, mda-lv2といった感じで移植されますが、このオリジナルのMDAのコードは汎用的なDSPコードとして書かれています。各プラグインフォーマットのプロジェクトは、それぞれのプラグインAPIを使った「ラッパー」となっているわけです。

この領域で新しく開発されているのが、MIDI 2.0サポートの追加です。共通コードでMIDI 1.0の表現力しか得られないのは残念な状態だったわけですが、MIDI 2.0が利用できるようになれば、note expressionや32ビットパラメーター(CCやNRPN = Assignable Controller)、アーティキュレーションなどを処理できるようになります。Appleがいち早くAudioUnitで実装しており、JUCEが追従しています2022.7.19追記: これは正しくない理解でした。AUでは任意の可変長バッファではなくMusicDeviceMIDIEvent()MIDIイベントをサポートしており、32ビットintの3値ではUMPをサポートする余地がありません。JUCEのソースでも現状対応する実装がありません。。CLAPも規格のレベルでMIDI 2.0メッセージをそのまま(VST3とは異なり、そのまま)処理できるようになっています。

プラグインフォーマットにおけるイベント定義

VST2の時代は、MIDI入力はそのままのかたちでプラグインが受け取って処理できるようになっていました。一方でプログラムチェンジとパラメーターも利用できるようになっていたので、ある意味役割がかぶっていた状態でした。

Steinbergはこれを問題だと考えて、VST3ではMIDI入力をパラメーターとしてプラグイン側でマッピングして処理させることにしました。その結果、VST3ではなまのMIDIメッセージを受け取ることができなくなりました。これはそれなりに大きな副作用があり、まずVST3プラグインでどんなDAWでも一意に復元できるようなMIDI出力が出せなくなりました(全てVSTイベントとして出力されるため)。そしてJUCEのようにVST3イベントをMIDIメッセージに変換したうえでオーディオ処理に渡す仕組みがあって、かつプログラム番号もMIDIのプログラムチェンジとならずにそのまま渡される仕組みになっていると、DAWにはMIDIのプログラムチェンジを入力したはずなのに、プラグイン側にはプログラムチェンジとして渡されない…といった問題もありました。JUCEのように「プラグイン側ではホストからのイベントは全てMIDIメッセージに変換してその範囲で処理する」仕組みになっていると、この影響をストレートに受けることになります。

CLAPは、VST3とは異なり、MIDI 1.0イベント、MIDI 2.0イベント、CLAPイベントの3種類がサポートされており、DAWはどの入力もそのままプラグインに渡せば良いということになります。

VST3は「役割が重複したら困るだろうからホストが全部われわれのVSTイベントに変換するのでそれを使え」という姿勢ですが、CLAPの場合は「複数の入力イベントで役割が重複するかもしれないが、その解決は自分でやれ」ということになります。

ノートエクスプレッション

ノートエクスプレッション (Note Expression) またはノート別エクスプレッション (Per-Note Expression) とは、プラグインに送信された全てのノートではなく、特定のノートだけに適用されるプラグインのパラメーターを実現する仕組みです。MIDI 2.0 UMPには含まれている命令ですが、MIDI 1.0の原規格には含まれておらず、MPE (MIDI Polyphonic Expression) という派生規格で別途実現しています。もしMPEが無ければ、一般的にMIDI 2.0がサポートされていないOSではMIDI入力デバイスから受け取ることができないでしょう。

(ノートエクスプレッションについては過去に一度踏み込んだ話を書いたことがあるので、ここで改めて踏み込むのは避けます。)

CLAP 1.0公開を機にオーディオプラグイン規格の何たるかを知る (2)オーディオプラグインの拡張機能の種類

連載2回目の本エントリーでは、オーディオプラグインの「拡張機能」としてどのようなものが存在するのか、CLAPとLV2を例としていくつか見ていきましょう。各拡張機能の詳しい内容には今回は踏み込みません(以降の回に期待して下さい)。

まずはCLAPにどのような拡張機能が定義されているのか、一覧を画像で示します。

CLAP extensions

画像にしたのは、他のプラグインフォーマットの拡張機能リストと簡単に比較してみるためです。次の画像はLV2の拡張機能の一覧です。

LV2 features 似たような名前の機能がいくつか並んでいるのが見て取れます。

CLAP拡張機能へのアクセス

CLAPでは、拡張機能がクリーンに整備されています。CLAPの拡張機能にはプラグイン拡張とホスト拡張がありますが、全てのプラグイン拡張機能clap_plugin_*_tとして定義されており、同様に全てのホスト拡張機能clap_host_*_tとして定義されています。プラグイン拡張のAPIは「プラグインが実装し、ホストが呼び出す」ものであり、ホスト拡張の機能は「ホストが実装し、プラグインが呼び出すもの」と理解しておけばOKです。

拡張の種類 実装者 利用者
プラグイン拡張 プラグイン ホスト
ホスト拡張 ホスト プラグイン

プラグインが実装する拡張は、clap_plugin_factory_tcreate_plugin()で返されるclap_plugin_tget_extension()メンバー(関数ポインター)の実装となる関数で、IDに対応するものを返すかたちで実装します。たとえばプラグインでstateを保存する機能(パラメーターが存在するプラグインではほぼ実装することになるでしょう)を実装する場合はこうです。

clap_plugin_state_t state_ext{my_plugin_state_save, my_plugin_state_load}; // それぞれ関数
void* my_plugin_get_extension(const clap_plugin_t* plugin, const char* id) {
    if (!strcmp(id, CLAP_EXT_STATE))
        return &state_ext;
    ...
    return nullptr;
}

プラグインがホスト拡張を呼び出す方法については少し追加説明が必要でしょう。これに関連してはまず、拡張ではありませんが、CLAPのplugin factoryとなるclap_plugin_factory_tcreate()の定義について説明します。

const clap_plugin_t *(*create_plugin)(const struct clap_plugin_factory *factory,
  const clap_host_t *host,  
  const char *plugin_id);

この2番目の引数はclap_host_t*という型になるのですが、これはホスト実装のポインターである必要はなく、プラグインが必要とするホスト機能を提供する実装でさえあれば十分です。clap_host_tには次のようなメンバーがあります(コメントを削っています):

typedef struct clap_host {
   clap_version_t clap_version;
   void *host_data;
   const char *name;
   const char *vendor;
   const char *url;
   const char *version;
   const void *(*get_extension)(const struct clap_host *host, const char *extension_id);
   void (*request_restart)(const struct clap_host *host);
   void (*request_process)(const struct clap_host *host);
   void (*request_callback)(const struct clap_host *host);
} clap_host_t;

ホストのメタ情報のほか、ホスト拡張機能を取得するためのget_extension()、ホストのmain (UI)スレッドで処理を呼び出すためのrequest_callback()、オーディオ処理を開始させるためのrequest_process()request_restart()などが定義されています。ホスト拡張機能はこのget_extension()で取得して使います。

一方で、筆者はCLAPが必須拡張と任意拡張を区別しないのは問題だと考えています。特定の拡張機能プラグインでサポートされていない時に、それでも正常にオーディオ処理を続行できるのか、それとも無効化すべきなのかは、区別できるべき情報です。

参考: LV2の拡張機能アクセス

LV2の拡張機能は他のプラグイン機構と比べると特殊です。RDFメタデータによる定義だけでほぼ全てが完結していてCヘッダーでは定数のURL定義しか含まれていないような拡張機能が少なからずあります。それらの拡張機能は、RDFのクエリだけでプロパティを取得したり設定できたりするように作られているため、追加のAPI定義を必要としないのです。

一方でRDFの操作によって機能を実現する仕組みは弱い型付けに基づいているため、すぐ間違ったコードを書いてしまいがちという問題があります。メタデータコンパイル時に検証できないため、メタデータを変更してコードを書き換えないことで問題がすぐ生じます。

前回のエントリでも書きましたが、LV2のRDFメタデータ中心の拡張機能設計は、動的に変わりうるメタデータ項目の扱いをややこしくするという副作用がありました。先にスクリーンショットで列挙したLV2拡張機能のうち、Dynamic Manifest、Morph、Resize Portの一部仕様は、LV2のメタデータが静的であることから生じる問題を解決する(すなわち、動的なメタデータ定義を可能にする)ために定義された拡張機能です。