aria2web: SFZ ARIA GUI extensions on Web UI (Part II)

前回の続きとなるエントリーです。今回はWeb UIをLV2 UIとしてどう統合するか、という話。

atsushieno.hatenablog.com

だいたい作る予定だった部分はほぼ完成したので、とりあえずこの開発はいったん完了とするつもりです。

github.com

復習と後半の導入

前回はSFZサウンドフォントをGUIからコントロールできるようにするARIA extensionをWeb UIで実現しました。UIだけだと何もできないので、これをsfizzというSFZのサンプラー(UIが特に無い)と組み合わせてホンモノのオーディオプラグインとして機能するものを作ります。

オーディオプラグインフレームワークの選択

sfzサンプラーはいくつか実装があるのですが、今回はOSSで一番期待値が高そうなsfizzを使うことにしました。同じ開発者がsfzformat.comというサイトの中でsfzプレイヤー(ライブラリ)の実装リストを公開しているので、この中にあるものはだいたい見ています。

わたしの開発の主目的は自分のLinuxデスクトップ環境で使えるオーディオプラグインを探すことであり、その次に自前のAndroidフレームワークで使えるサンプラーを用意することにあるので、オーディオプラグインフレームワークとしてはほぼ唯一解としてLV2を選択しています。

…というのが3月頃のわたしの選択だったのですが、sfizzの最近の開発動向を見ていると、どうやらVST3との2本立てになっているようです。VST3ではわたしがDAWに(…ではなくMMLなこともあるので、まあ「制作に」)使っているTracktion Waveformなどが未対応ですし、Android環境でも「まだ」vst3sdkがビルドできる状況ではないので、VST3はまだ選択肢にならないのですが、今後は可能性があるかもしれません。

LV2を使えるDAWは現状生粋のLinux向けDAW(Ardour, QTractor, Zrythmなど)がほぼ全てですが、以前にもちょっと言及したlv2vstを使うとVST環境でも部分的に使えます。

ちなみにsfizzでは以前はJUCEが使われていたのですが、JUCEのLinux環境サポートが貧弱だったこともあってか、JUCEは捨てられてLV2をダイレクトに実装する方向性に変わっていました(Linux系オーディオ開発者の間ではJUCE離れが進んでいたという話を3月に書きましたが、これもそのひとつです)。最近developブランチで開発が進んでいるVST3サポートもダイレクトな実装です。

sfizzにはLV2の特徴のひとつであるCVポートは必要なく、sfzファイル名を除いて全てのポートがfloat1つのパラメーターで表現できるので、技術的な理由でこれらをダイレクトに実装したということは無いはずです。

LV2 UIの独立ライブラリ設計

aria2webのUIをどうsfizzに組み合わせるのか解説する前に、いくつか説明しておくべきことがあります。まずはLV2ホストがLV2プラグインとLV2 UIプラグインを繋げる仕組みです。

LV2 UIは、コードの分離としては優れた設計になっていて、プラグインの共有ライブラリとは別に、プラグインUI用の共有ライブラリを指定するようになっています。これは暗黙的にオーディオプラグインUIの処理をオーディオ処理から分離する役に立っています。プラグインとUIは、次の2つの手段でやり取りします。

  • audio to UI: プラグインUIはプラグイン本体の出力ポートにsubscribeするようmanifest (.ttl) に記述することで、プラグイン本体からのデータ変更通知を受け取れます。
  • UI to audio: ホストがUIプラグインの初期化時に呼び出すinitialize()関数では、プラグインUIから操作などをプラグイン本体へ通知するときに呼び出すwriteFunctionという関数ポインタが渡されるので、UI実装のコードではこれを随時呼び出します。

オーディオプラグインにおいては、オーディオ処理とGUI処理には別々のスレッドが用いられます。オーディオ処理はリアルタイムで行われなければならないので、リアルタイムで処理することが想定できないイベントループをかかえるGUIは、独立して動作しなければならないためです。(LV2にはWorkerという拡張機能があり、オーディオスレッドでの処理が不適切である機能はこれを使用して実装しますが、GUIサポートはホスト側で別スレッドで処理することが最初から想定されているので、プラグイン側が気にすることはありません。)

LV2プラグイン実装のトレンドに関するフォローアップ

4月にLV2のGUIサポートのトレンドに関するメモを公開しました。LV2というかLinuxデスクトップではGUIフレームワークがいくつかあって、QtにもGtkにもバージョンがあるしX11ベースの他のツールキットもあってバベルの塔が崩壊した後の状態になっていて、それをsuilでまとめつつあるみたいな状態だ、という話でした。

このときは「suilを使えばまあ概ねどのGUIバックエンドで実装していても違いを吸収できるようになっているんじゃないか」と考えたわけですが(それで実際ある程度解決するわけですが)、何やらこれがZrythmの作者の目に止まったらしく、いろいろ教えてもらいました。

まずプラグイン開発のトレンドは概ね「X11UIを使わないとダメっぽい」という方向性に収束しつつあるようです。suilはホスト側の実装で、ホスト側のトレンドとしてsuilが使われているのは間違っていないわけですが、X11レベルで実装していれば互換性問題が生じない、というのが理由であるようです。X11でダイレクトに使えるUIフレームワークには、FLUTなどGL系のライブラリ、JUCE GUI、LV2であればpuglという独自のライブラリがあります。(そういえばGuitarixがX11でUIを書き換えているという話を書いたこともありますね。)

suilで何が解決しないかというと、まずそもそもGtk2とGtk3などを1つのアプリケーションで混在して利用することができない(らしい)という話があります。gtk2のglibのアプリケーションループとgtk3のglibのアプリケーションループが共存できない、と考えると、まあ納得感があるでしょう。Qt4とQt5も同様の状況なのかは分かりませんが(Qt4は触ったこともない)、その可能性は十分にあると思います。

上記のトレンドに関するメモでも言及しましたが、そもそもGUIフレームワークごとにホストから渡されてくるGUIウィジェットのparentが何になるのかが明確に規定されず、LV2 UIの仕様自体が詰めきれていないという問題もあります。LV2の仕様ではGtkUIやGtk3UIは「バイナリディストリビューションで使用すべきではない」と記述されているのですが、(わたしの理解としては)バイナリ配布なしに任意のプラグインUIを使用する方法は現実的にあり得ず、要するに仕様として失敗しているところです。LV2仕様は所詮個人で決めているものであり、この種の技術的な回答を提示できない問題が生じるのは仕方のないところでしょう。

LV2 External UI

aria2webのUI統合…というかLV2プラグイン統合…の最初の問題は、Gtk3UIとしてwebview.hからGtkWindowを取得してもホストから渡されるparentにattachできないことでした。以前のUIトレンドの記事も書きましたが、Zrythmから渡されるparentはGtkEventBoxで、event boxにwindowを子として追加することはあり得ないですし、webview.hには親としてGtkWindowしか渡せなかったので、Gtk3UIは早々に諦めました。

とはいえ、X11UIにしたところで親としてはwindowが渡されることに変わりはなく、webview.hとの相性はよくありません。webview.hを諦めて独自実装するのであれば、前述の「suilでも解決しない課題」のことを考えるとX11レベルでやるしかありませんが、WebkitではWebkitWPEくらいしか解決策が無く、これは組み込み環境用にビルドできるという以上のものではない(X11統合があるわけではない)ので、自前で実装するのはしんどいぞ…となりました。WebKit以外の選択肢としてはCEF (Chromium Embedded Framework) がありますが、CEFを組み込むとかなり巨大なバイナリになってしまうので、可能な限り避けたいところです。

それならば、いっそCarlaで採用されているexternal UI方式ではどうだろうか、と考えました。Carlaがどういうアプローチで採用しているのかは分かりませんが、external UIではホストからのコントロール インターフェースとしてshow/hide/runだけを規定するので、それをUIが自前で実装する、というスタイルです。現在aria2webではこれが採用されています。

前述のLV2 GUIのトレンドのメモでは、APIとしてUIとオーディオ部分を分離して処理できるような仕組みを想定していない、と理解していたのですが、実際にコードを組んでみると、この連携部分はmanifestのport subscriptionとwriteFunctionだけで実現できました。

aria2webの初期設計では、UIがinstantiate()の呼び出しによってホストから起動されたら、webview.hを使って手抜きで実装したUIを表示して、そのAPIでJSオブジェクトにC関数コールバックを割り当てて、それが呼び出されるたびにwriteFunctionを呼び出してプラグイン側に反映する、というアプローチで実装しました。これでZrythmからのUI生成が実現できました。

LV2 UIのプロセス分離

Zrythmから表示できるようになったaria2webですが、webview.hでは内部的にwebkitgtkが用いられており、これはQTractorで使えませんでした。webkitgtkがGtkアプリケーションループの存在を前提としていたためです。suilが解決すべき問題ではないかという気はしますが、とりあえず方向転換が必要になりました。そこでUIプロセスを分離することを思いつきました。

オーディオプラグイン機構全般にいえることですが、多くのホスト(DAW)ではユーザーが楽曲の打ち込みで指定したプラグインを、インプロセスでロードします(この辺りの技術的課題については1年前にメモをまとめてあります)。これはLV2ホストの場合も同様です。

ホストとプラグインがひとつのプロセスで動作するということは、ホストがsuilで別々のUIフレームワークを繋ぎ込めたとしても、プラグイン同士で共有ライブラリを動的にロードしていた場合に、その前提バージョンが異なっていると、いわゆるDLL hellのような状態になりかねないでしょう。(プラグインのローカルパスから共有ライブラリをロードできない場合などもあり、静的にリンクすることが多いとは思いますが、それが適切でない場合もあるでしょう*1。)

これは個人的な分析として、どちらかといえば、LV2はプロセス分離モデルに関する意識が低い側面があります。そのひとつがLV2サポートに不可欠なURIDのモデルです。URIDとは、LV2プラグインの様々な場面で使われている「名前空間」を表すURI文字列を、オーディオ処理の過程でも無理なく使えるint32_tに変換する仕組みです。ホストとプラグインは、URIDのmap/unmapという機構(関数ポインタを含むstructで表現される)を用いて、文字列と整数を相互変換します。もしプラグインが分離プロセス上で動作していたら、メンバーとして渡された関数ポインタをそのまま関数として呼び出すことはできません。

プラグインプラグインUIは別々のプロセスで動作できるのでしょうか? 前述したオーディオとUIの双方向のやり取り(特にwriteFunction)をそのままプロセス分離して利用することはできませんが、ホストとUIプラグインのコードそのものは同一プロセスで動作させつつ、UIプラグインのコード自体から分離プロセスをspawnして起動することは不可能ではありません。オーディオポートからの通知はそのままUIプロセスに流せばよく、UIの変更もそのままwriteFunctionの呼び出しに繋げれば良いだけの話です。

aria2webにはWeb UIを単独で表示できる(けど音は何も出さない)aria2web-hostが存在したので、プラグインUIが受け取った通知をstdinから読み取り、UIイベントをstdoutに書き出す、という単純なパイプでプロセス間通信を実現しました。spawnで実装するのが面倒だったので、gitlabで発見したtiny-process-libraryというクロスプラットフォームC++ライブラリを使っています。*2

READMEにも書いているのですが、図面にするとこんな感じです。VSCodeに統合されたdraw.ioで描いたやっつけ図面…!

https://raw.githubusercontent.com/atsushieno/aria2web/051504c/aria2web-ipc.drawio.svg?sanitize=true

何はともあれ、UIプロセスを分離したことによって、QTractorからでもUIがロードできるようになりました。ちなみにここまで進めても、オリジナルのsfizzには名前空間衝突を避けるためのURI変更とui:uiのmanifest要素追加以外では、一切手を入れていません。

オーディオ処理部分とのやり取りとウィンドウ管理

LV2のオーディオプラグインとのやり取りは、通知ポートとwriteFunctionの2つで行われる、という話を書きました。

通知ポートは、オーディオ側では「出力」ポートとなっていて、まず「イベント」はLV2UI_Descriptorで登録したport_eventに届きます。

void aria2web_lv2ui_port_event(LV2UI_Handle ui, uint32_t port_index,
     uint32_t buffer_size, uint32_t format, const void *buffer)
{
    ...
    
    if (port_index == ARIA2WEB_LV2_NOTIFY_PORT) {

また、UIプラグインでmanifestにui:portNotificationを追加すると、指定したport(ここではsymbolで指定)に届いたイベントを受け取れます。

<https://github.com/atsushieno/aria2web#ui>
  a extui:Widget ;
  ui:binary <aria2web-lv2ui.so> ;
  ui:portNotification [
        ui:plugin <https://github.com/atsushieno/aria2web> ;
        lv2:symbol "notify" ;
        ui:protocol atom:eventTransfer
  ] .

AtomとPatch

イベントはAtomというLV2独自のバイナリデータのフォーマットで渡されてきます。Atom形式のデータを構築する方法についてきちんと説明するとそれだけで数千字になってしまうので今回は省略します…(割と大変だったのでホントはちょっと書こうと思っていましたが…)。

writeFunctionは次のような形式で定義されるのですが、

typedef void(* LV2UI_Write_Function) (
    LV2UI_Controller controller, 
    uint32_t port_index, 
    uint32_t buffer_size, 
    uint32_t port_protocol, 
    const void *buffer)

引数が先のport_eventとよく似ています。port_protocolには主にfloatの値1つだけを渡す場合と、Atom Event形式のデータを渡す場合があって、一般的なパラメーターはfloatプロトコルでbufferポインタの先にfloat値が1つ入っているだけです。

sfizzでややこしいやり取りは、sfzファイル名を渡す場合にこのAtom形式にファイルパスを変換して送信する部分だけで、ここにはLV2 PatchというAtom上で成立するパッチDSLのようなものが使われています。これも詳しくは解説する余白が足りないので省略しますが、patch:getというメッセージで「sfzファイル名を送れ」とオーディオの入力ポートに送信すると、sfzファイル名を含むpatch:setというメッセージが通知ポートに届きます。MIDI 2.0のProperty Exchangeっぽい感じです。

ウィンドウとインスタンス管理

プラグインUIは次のようなタイミングでプラグイン本体から情報を受け取る必要があります。

  • ウィンドウの表示・非表示を変更したとき
  • プラグインの削除などに伴ってUIを破棄した場合

これらについては、前半で少し触れた、Carlaを開発しているKXStudioの"External UI"のインターフェースに沿って、show / hideといったコールバックが呼び出されるので、それに従ってウィンドウ表示を調整します。

ウィンドウの表示制御は、aria2webの場合はあくまで「ホストから行う」ことにしており、ユーザーが閉じることは想定していません。ZrythmではGtk3UIなどのウィンドウを自分で閉じることも出来てしまうのですが、これはホスト側からウィンドウの状態を把握できているから出来ることでもあります。external UIではホスト側がこれを把握することは出来ず、またプラグインUIからイベントを受け取ることもありません。想定外の状態を管理することになってしまうので、少なくともexternal UIを使う時はウィンドウを閉じるボタンは消しておいたほうが良いでしょう。

プラグインUIがshow / hideイベントを受け取ったら、子プロセスにパイプのstdioで命令を伝達しています。

プラグイン側からのSFZファイルの変更通知とUIへの反映

プラグインUIの表示で厄介な部分のひとつに、オーディオ部分にSFZファイルの変更があった場合にそれをUIに反映させるまでの流れをどう作るかという課題があります。

基本的には変更が生じたらport_eventで通知ポートから受け取ったファイル名をもとに子プロセスのウィンドウにstdioのパイプで通知を伝達します。現状SFZファイル名の更新くらいしか通知しないので、SFZ (filename)\nと書くだけです。問題は、DAWが楽曲をロードした場合など、プラグインの初期化タイミング(トラックがロードされた時点で初期化)とGUIの初期化タイミング(UI表示が指示された時点で初期化)の、どのような組み合わせについても対応できる必要があるということです。

aria2webの現在の実装では、UIがinstantiateされ子プロセスでのJSのロードが完了した時点で、親プロセス(プラグインUI側)に初期化完了の通知を送ります。プラグインUIはこれをstdioのパイプで受け取ると、sfizz本体にpatch:getを送って、SFZファイル名を通知ポートに送ってもらいます。これが届いたら、それを子プロセスに送って表示を更新します。

このややこしい手順を踏まないと、「子プロセスがまだWeb UIの処理に必要なJSをロードできていない時点で表示更新のためのJS式の評価が呼び出されてしまってエラーになる」といった事態が生じてしまいます。UIプラグインと表示プロセスの間でプロトコルが確立していると、そういった事故を防ぐことができます。

未解決の課題と応用

だいたい以上のような実装を終えた時点で、オーディオプラグインとしての機能はだいたい完成したことになるのですが、課題もいくつか残っています。一番直感的に困るのは、ARIA GUI上にknobやsliderやswitchが存在していてこれでサウンドを調整できるはずなのに、これらはsfizzのパラメーターとしてプラグインのstateに保存されない、ということです。

sfzではこれらはMIDIイベントとして受け取る前提になっていて、stateではなく楽曲のMIDIデータのような部分に保存されていることが前提になっています。そのため、これらをsfizzの今の実装とは別に保存しないと、Kontaktなどを使っているような気分でプラグインのツマミを調整しても記録が残らず、データが失われたような使い勝手になってしまいます。割と重要な課題…

あとはWebKitGtkの問題のせいで、shiftキーを押した状態でwheelイベントを処理しようとすると、なぜかxy軸がひっくり返る(縦wheelなのに横扱いになる)みたいなバグがあったり…

UIの問題とは別に、ひとつ目的にしていたSFZのギター音源をsfizzで鳴らすという試みなのですが、sfizzでサポートされていないSFZ 2.0のopcodesがふんだんに使われているということもあって、まだ音が鳴らないというのが現状です。ただkey switchも適切に入力できているかわかっていないし、サックス音源などは鳴っているので、sfizz本体の開発をもう少し待ってからでも良いだろうと思っています。

他にもいくつかissueとして課題になっているのですが、「プラグインUIを音声処理プロセスから分離しつつホストと協調的に制御してオーディオプラグインらしく振る舞う」という実験にはどうやら成功したと言えるので、これをAndroidフレームワークにも適用していこうと思っています。

*1:たとえば依存ライブラリにsecurity fixがあった場合に必ずアップデートを自前でリリースできるか、などの考慮事項があります

*2:ちなみに今回使っているサードパーティライブラリ、webviewもhttpserver.hもクロスプラットフォームです(自分のコードではGtkWindowにキャストしている部分などはありますが)。