JUCEベースのオーディオプラグインを自分のフレームワークに輸入する

こちらは二部構成の後編になる。前編はこちら。

atsushieno.hatenablog.com

タイトルは対照的ではないけど、今度は「JUCEでカスタムAudioPluginFormatをホストする」の反対側、すなわちJUCEを使って自分のプラグインフレームワーク用のオーディオプラグインをビルドできるようにするために、JUCEモジュールを作成する。

JUCEの本質的な特徴のひとつは、1つのコードベースで複数フォーマットのオーディオプラグインを簡単にビルドできることだ。すなわちオーディオ処理の部分はコード共有できるので、自分が独自にオーディオプラグインフレームワークを開発しているときは、これを活用できるようにしたい。

ホスト側をサポートするだけでは、DAWの基盤で自分のフレームワークをサポートすることは出来ても、オーディオプラグインのほうは実例が増えないので、JUCEをプラグイン作成のほうでサポートできるようにするのが手っ取り早いやり方だ。

目次

JUCEでサポートで必要になる機能追加

JUCEは、ごく大まかに言えば、フレームワークライブラリと、ビルドシステムであるところのProjucerの2つから成り立っている。オーディオプラグインが単一の共有ライブラリで完結していることはおそらく多くなく、一般的には何らかのバンドル(パッケージ)になっている。ライブラリを利用してコードを実装できるのはフレームワークの部分だけであって、パッケージングの部分ではビルドシステムが重要になる。これらは切り離して考えたほうがよい。つまり:

  • オーディオ処理コードの自家製フレームワークへの繋ぎ込み → フレームワーク側の拡張
  • リソースの生成とパッケージング(バンドル作成) → Projucerのビルドシステムの拡張

この2つのうち、Projucerの拡張は、あまり推奨できないし、自分でもやらなかった。ただそれがなぜなのかを説明するためには、フレームワークの方から先に説明する必要がありそうだ。

JUCE本体に手を加えずに機能を拡張する

どんなライブラリやフレームワークも、いったん安定版をリリースして広く使われるようになったら、可能な限りAPI互換性を維持するようになる。互換性が破壊されると、自分が書き換えられないユーザーのコードがビルドできなくなるからだ。開発者はある日突然自分が使っていたライブラリのアップデートのせいでビルドが通らなくなると、アップデートしなくなるかもしれない。つまりユーザーが減る。これはユーザーベースが無くても良い開発者の場合は問題にならないが、そんなケースは相当まれだろう。

Androidや.NETの開発者としてのキャリアが長い自分から見ると、JUCEは比較的APIを破壊的に変更しているほうだと思うが(基本的には良い特徴ではない*1)、それでも破壊的変更は原則として不必要には行われないはずだ。

JUCEに何かしらの機能を追加する場合によく見られるのが、JUCE本体をforkして、必要なコードに手を加えることだ。これは実のところあまり持続性が無いやり方で、可能な限り避けたほうが良い。いったんforkを維持する方針を採ってしまうと、ROLIが新しいバージョンをリリースするたびに、コードの変更からコンフリクトを解決しなければならなくなる(これは変更が大きければ大きいほど問題になる)し、実行時の意図しない挙動の変更によって問題が生じる可能性も広がる*2

もっともJUCE本体にかかるさまざまな特徴*3が、これを困難にしている。だからJUCEのforkは他のフレームワークに比べたらずいぶん多い。どれも開発者にとっての関心の対象外になった時点で追従しなくなっている。

JUCEはなるべくforkせずに、基本的にはJUCE APIに沿って、破壊的に変更されないAPIを使ったモジュールやライブラリとして実装したほうがよい。

Projucerサポートの部分をあきらめる

さて、ここでもうひとつの本題であるところのProjucerに戻ろう。非破壊的変更の原則はフレームワークにしか適用されない。生成されるビルドスクリプトMakefileなど)には常に破壊的な変更が入りうる。だからこれに依存する仕組みは入れられない。

アプリケーション開発者に公開されていて破壊的変更が無いことを暗黙的に約束されているのは.jucerファイルにおける設定部分のみだ。それ以外ではProjucerにはAPIは無いので、外部から利用する手立てがない。自前フォーマットのサポートがProjucerに入っているはずはないので*4、Projucerで行われている処理に機能を追加するためにはforkするしかない。しかしforkしたら持続性が無いことは説明したとおりだ。

ではどうすべきか? Projucerで行われている部分はもうあきらめて、自分でパッケージバンドル作成ユーティリティを作ったほうがよいだろう。幸いなことに、われわれ(誰)に必要なのは、多様なプラットフォーム向けにさまざまなパッケージングを求められるVST3のようなフォーマットに手を加えることではなく、あくまで自分のフォーマットに沿ったパッケージングだ。

「理想的な」ビルドシステムへの繋ぎこみを考えるのであれば、ProjucerはCMakeをサポートしていて(CLionサポートなどと称しているが、要するにCMakeサポートである。JUCEは伝統的にCMakeを毛嫌いしていたので正面から認めたくないだけだ)、CMakeは現状では最も幅広くクロスプラットフォームでサポートされているビルドシステムなので、CMakeモジュールとして自分のパッケージフォーマットをビルドする仕組みを作るのが良いかもしれない。

ProjucerのCMakeサポートはそこそこやっつけ仕事感があるので(たとえばファイルパスをLinuxMakefileから取り込む部分が雑でCMakeではビルドできないなんてことがざらにある)、話半分で聞いておいたほうがいい話かもしれない(「理想的な」というのはエクスキューズだ)。

次節からコードに踏み込んで解説することになるのだけど、Projucerでやっている仕事をどうやって実現するかについても後ほど改めて触れていく。

プロジェクト構成 - Audio Plug-inプロジェクトの概要

さて、コードの話に踏み込むまでにすっかり説明が長くなってしまった。JUCEのオーディオプラグインプロジェクトの「コードを」取り込む話に入っていこう。

最初に、そもそもJUCEのオーディオプラグインプロジェクトについて書いておこう。JUCEのアプリケーション テンプレートには、GUI ApplicationやOpenGL Application、Audio Applicationなどと並んでAudio Plug-inというものがある。JUCEでオーディオプラグインを開発する人はほとんどこれを利用しているはずだ。

プロジェクト テンプレートによって何が異なるかというと、Projucerで表示され.jucerで保存されるオプションが変わってくる。Audio Plugin-inの場合は、ここにプラグインの基本情報(名前とかベンダーとか)やVSTAU・AAXなどに固有のオプションが入ってくる。これが生成される.vcxprojや.xcodeproj、Makefileなどにも影響するというわけだ。

オーディオプラグインプロジェクトではstandaloneアプリケーションをビルドすることもできる。プラグイン実装のロジックをデバッグする時には大抵これを使うことになるだろう。繋ぎこみの部分にバグが入ってくることは通常はあまり考えなくてよいし、それは基本的にはJUCE本家の開発者がやるべきことだ。もっとも、われわれ(?)は独自のフレームワークへの繋ぎこみを実装する立場なので、この意味ではJUCE開発者のポジションに近い。

独自のライブラリへのglueコードを構築する手段として一番便利そうなのは共有ライブラリで、ProjucerではStatic LibraryやDynamic Libraryのプロジェクトも作成できるが、これではプラグインメタデータ生成が行えないし、standaloneプロジェクトをテンプレートから自動生成してくれるAudio Plug-inのほうが便利だ。何より既存のプラグインを取り込んでサポートしたいのであれば、こちらをサポートする必要がある。

AudioProcessorの"Wrapper"を作る

Audio Plug-inプロジェクトを新規作成すると、コードとしてはjuce::AudioProcessorの派生クラスが定義される。これを自分で実装することになる。API自体はそんなに難しくはない。ホストを実装する時に出てきたjuce::AudioPluginInstanceprocessBlockと同じだ。そもそもAudioPluginInstanceAudioProcessorから派生しているので、同じ関数をプラグインを作るときにもoverrideするということだ。

Audio Plug-inプロジェクトのコードの部分は大半がこの実装になるので、実装すべきコードについての説明はもうほぼ終わりだ(!)。あと他にAudioPluginEditorを実装している場合もあるが、これを繋ぎこむ処理が必要になる場合については話がややこしくなる(そもそも繋ぎこむ先のプラットフォームGUI固有の話になる)ので、基本的には何もしなくてもJUCE側でよろしくやってくれることを期待することにする。

自分のフレームワークに繋ぎこむためには、むしろ自分のフレームワーク側でどのようなプラグインのエントリーポイントを用意すべきか、どんなコードでオーディオ処理を行うべきか、といったところから考えなければならない。これはしかし自分のフレームワーク向けのプラグインを開発するのと、基本的には何も変わらないだろう。自分のフレームワークには自分なりのオーディオバッファデータ構造があるはずだが、概ねfloat配列などに帰着するだろうから、多くの場合はフォーマットの変換処理までは必要ないだろうし、なるべくコストのかからない方式で変換してやると良いだろう。実行時にメモリ確保しないようにすることが重要だ。もちろんMIDIメッセージに関してはこれ単純変換で済むという話は当てはまらない。

JUCEがVSTやらAUやらのサポートをビルドするときに何をしているかというと、基本的には.jucerで指定されたオプションに基づいて、C++コードに何を有効にしているかを宣言する#defineを追加して、その状態次第で各プラグイン向けのコードがコンパイル対象になる。たとえばVST3の場合はJucePlugin_Build_VST3が有効になるとjuce_VST3_Wrapper.cppのコードがコンパイル対象になる。この中にVST3プラグインで必要になるGetPluginFactory()が定義されているので、VST3用にビルドされたコードはVST3プラグインのコード部分として機能する、というわけだ。

これらは公開APIになっている必要はないので、juce_audio_plugin_clientのAPIリファレンスを眺めても、プラグイン固有のAPIは何も出てこないというわけだ。なのでコードも別にWrapperという名前を付けなくても良いのだけど、伝統的に他のプラグインフレームワークAPIの処理を自分たちのAPIでwrapする感じになるのでwrapperという名前がよく使われるようだ。

ちなみにJUCE側にはwrapperの基底クラスとして使うべきものは何もないが、wrapperを実装するためにはプラグインプロジェクトで作成されるAudioProcessorインスタンスを取得出来る必要がある。これはcreatePluginFilter()という、Projucerが自動的に生成する非公開の関数を呼び出すことで使えるようになる。

作ったwrapperはJUCEモジュールとして再利用できるようにしておくと、プラグインを移植する際にモジュールを追加するだけで済む…可能性が上がる。実際に可能かどうかは、次節で説明する残りの作業の内容次第だ(プラグインフォーマットによって変わる)。

Projucerに代わってメタデータをコードから生成する

さて、ここまでコードの実装を頑張ったところで、このプラグインについてビルドして利用できるのはStandaloneビルドのアプリケーションとSHARED_CODEと呼ばれるstaticライブラリのみだ。自分のプラグインフォーマットのためのビルドスクリプトでは、これをリンクして共有ライブラリなどを完成させなければならない。前述の通りProjucerは使えないので、これは自前で実装する必要がある。

もう一つ、残されているのは一般的にプラグインに必要とされるメタデータ生成だ。個別のプラグインAPIでは、コード中のAPIとして実行時に取得可能になっている可能性もあるが、JUCEではそのようなAPIは用意されていないので、ユーザーもそのようなコードを実装してはいない。

ではどうやってVST3やAUメタデータが生成されているかというと、.jucerファイルで指定された作者情報などは、ProjucerがC++#defineディレクティブに変換していて、各wrapperではこれを利用しているのである。juce_audio_plugin_clientの公開APIには含まれていないので、あくまで自分のモジュール内で、該当するディレクティブを見つけて、その内容を処理してメタデータを生成するコードを実装するしかない。

Projucerはもちろん独自プラグイン形式のメタデータを生成してくれないので、この枠組みの中で自分にもできることは、wrapperコードの一環としてメタデータを生成する関数を定義して、これを利用する外部ツールをビルドすることだ。ビルドされたSHARED_CODEのstaticライブラリをリンクすれば、このコードを呼び出せる。

このアプローチを採用しているのが、ADLplugというプラグインのプロジェクトで使われているJUCEのforkに含まれるLV2サポートの実装*5。このコードで定義されているLV2リソース生成のためのコードは、プラグイン本体としては全く実行されないが、LV2のTurtle形式のメタデータを生成する際に使われる。JUCEでLV2をサポートするためにはこういうアプローチが採用されている。

まとめ

ここまでの流れを踏まえて、Audio Plug-inプロジェクトを移植する全体的な作業をまとめる。

  • もしまだ作っていなければ、独自プラグインフレームワークへ繋ぎこむモジュールを実装する
  • 移植対象のプラグインのソースの.jucerをProjucerで開いて、そのモジュールを追加する
  • ビルドする
  • もし必要かつまだ作っていなければ、繋ぎ込みモジュールにメタデータリソース生成のためのコードを実装して外部から呼び出せるようにする
  • メタデータリソース生成(無理がなければパッケージバンドル生成)のためのツールを用意する(エントリーポイントからその関数を呼び出すようにして、Audio Plug-inのライブラリにリンクするだけで十分)

これでいけるはずだ。出来てしまえば難しいことはあまり無いだろう。

*1:ゼロ・トレランスで破壊的変更を拒絶するAPIは成長に対するコミットメントが無く未来もないことが多いので、「基本的には」という留保を付けている。C++ライブラリがそもそもそういうものかもしれない。

*2:挙動に変更に起因するバグは外部モジュールにしていても当然発生するけど、意識のすり合わせのない「変更」ではその可能性がぐんと上がる

*3:伝統的に外部モジュールによる機能拡張をあまり歓迎しなかったC++コード基盤とか、多様な環境をより簡単にサポートするためにソースから全てビルドするがゆえにバイナリ配布によるAPIを考慮してこなかったとか、そもそも「Juleの個人的なユーティリティだから改造したいやつは好きに使え」だった歴史とか

*4:ちなみに今回コードを書いていて発見したのだけど、公式のProjucerでLV2サポートを追加しようとしていた痕跡が今でもコードベースにある

*5:これ自体はDPFという別のフレームワークの開発者による実装かもしれない