オーディオプラグインのインスタンス生成スレッドに関する覚書

AAP (android-audio-plugin-framework) のホスト実装のひとつとして、JUCEのAudioPluginHostを利用しているのだけど、最近全面的に再構成しているホスティングAPIに基づいてJUCE統合(AndroidAudioPluginFormat)を書き直していて、だいぶ行き詰まった問題がある。プラグインを生成するスレッドの問題だ。結論からいうと、AAPやAU v3ではmainスレッド以外からプラグインを生成するか、あるいはmainスレッドをブロックしないことが求められる。mainスレッドをブロックするのは大抵のプラットフォームではアンチパターンあるいは禁止となっているから、ロジックとしては妥当だとわかってもらえるとは思う。

AAPを設計しているのは他ならぬ自分自身なので「求められる」などと他人事みたいに書いているのは不自然に見えるのだけど、自分の趣味で求めているわけではない。

AAPはホストとはAndroidプラットフォームとエコシステムの構造として必然的に別アプリケーションとなり、インスタンスの生成においてはプラグインAndroid ServiceとしてApplication.bindService()を呼び出して生成する必要がある。bindService()を呼び出した後、その結果はそのメソッド呼び出しで引数として渡したServiceConnection(インターフェース)の実装のonServiceConnected()メソッドがコールバックされるという流れで渡される。このonServiceConnected()はメインスレッドで実行されることになるので、メインスレッドが他のコードでブロックされていると、onServiceConnected()にお鉢が回ってこないことになる。

自分の場合、メインスレッドでkotlinx.coroutines.channels.Channelwait()で待機させておいてonServiceConnected()signal()を送るようになっていて、一生先に進まない状況に陥っていた。

juce_audio_processorsを使う場合の注意点

JUCEを使ってオーディオプラグインインスタンスを生成するコードを書いている場合、そのインスタンス生成にはAudioPluginFormat(Manager)::createPluginInstanceAsync()を使うことが期待される。createInstanceFromDescription()は同期APIなので、これを使うと生成結果が返ってくるまでその実行スレッドがブロックされることになる。

std::unique_ptr<AudioPluginInstance> AudioPluginFormat::createInstanceFromDescription (
        const PluginDescription & ,
        double initialSampleRate,
        int initialBufferSize,
        String & errorMessage)

void AudioPluginFormat::createPluginInstanceAsync (
        const PluginDescription & description,
        double initialSampleRate,
        int initialBufferSize,
        PluginCreationCallback)

AudioPluginFormat(Manager)::createPluginInstanceAsync()にはひとつ注意すべき事項として、実際のプラグイン生成処理が内部的にMessageManagerを経由してmainスレッド上で行われてしまうという問題がある。つまり、たとえ自分のホストアプリケーション上ではmainスレッド上でプラグイン生成されないように(juce::Thread等を使用して)別スレッドでこの関数を呼び出していたとしても、実際のインスタンス生成処理はmainスレッドで走ってしまう。

もしプラグインフォーマットのホスティングAPIを実装できる立場であれば(筆者はそう)、mainスレッドでのインスタンス生成を「禁止」しないようにできるが、そうでなければ、juce::AudioPluginFormatを自ら実装して、createPluginInstanceAsync()の実装の中でさらに別のnon-mainスレッドに処理を非同期に実行させる必要があるだろう。

プラグイン・サービス接続処理とインスタンス生成処理の切り離し

オーディオプラグインインスタンス生成はMIDIのポート接続と似ている側面がある。1つのMIDIバイスに接続して複数のMIDIポートを利用することがあり得るように、同一のプラグインの複数のインスタンスを生成するのは一般的なシナリオだ。

Androidアーキテクチャの場合、別アプリケーションのServiceに接続するのは1つのBinderでも、そこから複数のプラグインインスタンスを生成して利用するという構成になる。Android MIDI APIの場合、MIDIバイスへの接続はMIDI_SERVICEへの接続という非同期処理から始めることになるが、いったん接続したMIDIバイスのポート接続は同期的に開くAPIになっている。

仮想MIDIバイスであれUSB/BLEデバイスであれ、対象Serviceとのやり取りはIPC(Androidの場合はBinder)ベースになるので、ポート接続も非同期であるべきではないかとも思えるけど、いったん接続したMIDIバイスとのやり取りにはrealtime IPCが活用され得るため、スレッドの切り替えを伴う非同期APIではむしろ適切ではない可能性がある。

これと同じことがオーディオプラグインインスタンス生成についても当てはまるといえそうだ。AAPの場合は、接続処理は必然的に非同期にならざるを得ないが、すでに接続済みのAudioPluginServiceであれば同期的にインスタンス生成を行える。Androidフレームワークでは現状AAPホストのような任意のアプリケーションにおいてrealtime Binderを生成する窓口が開かれていないようなので、あくまで一般的なpthreadの優先度でしか接続できないが、それでも非同期スレッドによる切り替えが発生しないほうが良いだろう。

3月の開発記録 (2022)

早いもので3月が終わろうとしています。2月の終わりから全く世界情勢が安定せず、誰もが落ち着かない雰囲気の中で日々を過ごすことになったと思います。自分も例外ではないところですが、多かれ少なかれ影響を受けつつも、自分の作業は進められるだけ進めておきたいところです。

AAP hosting overhaul

2月からずっとやっていて、今日3/31にようやくmainにマージできた作業です。

2月はAAPのnative (C++) hosting APIの仕切り直しに着手していましたが、3月も引き続き泥沼に入っていました。native hosting APIは現時点では (1) MidiDeviceServiceの実装と (2) aap-juce-plugin-host (JUCE AudioPluginHostの移植)の2つしかなく、aap-juce-plugin-hostは「起動時にインストールされているオーディオプラグインの全てをbindService()で接続する」という割とシビアな実装になっていました。これには当然ながら(?)理由があって、まずBinder APIに基づいてServiceと接続する必要があったため不可避だったのです。が、aap-juceに基づくプラグインの移植は今や↓のように大所帯になっていて(README.mdからのコピペ)、今やこれを全て接続するわけにはいきません:

そういうわけで、native hosting API仕切り直しにあたっては、native APIからServiceのAPI…を操作するAAPのKotlin API…をJNI経由で呼び出してBinderを生成する(そのBinderはJNI経由でネイティブコードに渡されてNative Binderとなるわけで多重に境界を往復するわけですが)…という仕組みを作りました。

fixing JUCE Thread

構想としてはシンプルですが、よくある話ながらAudioPluginHostが実行できるようにコードを書き換えて動作させてみたら全くうまくいきません。まず自分のライブラリのクラスをJNI経由で呼び出そうとしてもClassNotFoundExceptionで堕ちてしまう。JNIのClassNotFoundExceptionはJNIをそれなりに使ったことがある人なら、あるいはJNI経由で他言語とinteropするフレームワーク(XamarinとかReact Nativeとか)のユーザーであれば馴染みがあると思いますが、JUCEでも同じ目に遭いました。

この話は前回ここに書いたネタに繋がってきます:

atsushieno.hatenablog.com

ここにまとめたパッチは、その後この記事の英語版を作成してからJUCEへのPRとして登録したのですが(長らく「contributionは受け付けない」方針だったJUCEが1月にポリシーを変更していたのです)、現状音沙汰なしです(issueにしろPRにしろJUCEはだいたいcontribution体験が悪いのであんましお勧めしないです。JetBrainsとかは割とおすすめ。dotnetもそれなりにいいほうだと思います。)

JUCE AudioPluginHostをいじっている過程でこのパッチを作成したということは先のエントリ(日本語)でも言及している通りで、まずはこの問題を追及してパッチを作る作業でそこそこ時間が溶けました。

async-ify plugin instancing API

スレッディングの問題を片付けたら、いよいろJNI経由でContext.bindService()を呼び出してAudioPluginServiceとbindするコードを実装しました。

bindService()は呼び出してもすぐに返ってくるもので、実際にServiceに接続できたらServiceConnection.onServiceConnected()というメソッドにコールバックが返ってくるようになっています。そのため、JNI経由で呼び出すインスタンス生成のメソッドではkotlinx.coroutines.channels.Channelインスタンスを作って.wait()させておいて、onServiceConnected()のコールバックの中で.send()して続行させるコードを書いていましたが、これがまた案の定うまくいかずにハマりました。AudioPluginHostでaap-juceのjuceaap_plugin_clientモジュール経由でAAPの新しいnative APIプラグイン インスタンスを生成しようとしても、途中で返ってこなくなるのです。

しばらく調べてみると、ServiceConnection.onServiceConnected()は(生成スレッドを問わず)常にmainスレッドで呼び出される仕組みになっていました。JUCEのアプリケーション・ループはmainスレッド上で動作しており、そこから呼び出されるインスタンス生成のネイティブ関数もmainスレッドで動いていたので、ここでブロックされていたわけです。

この問題も別エントリでもうちょっとだけ詳しくまとめようと思いますが、結局この問題を解決するために、AAPのAPIにasyncificationを施すことになりました。"asyncification" というのは一部で使われている非同期API化(あるいは非同期APIの提供)をあらわす俗語です。xxx()メソッドに対応するxxxAsync()を生やすやつです。AAPの場合、同期APIで実装すること自体が無理なので、非同期APIのみで作り直すことになりました。

今まで同期APIで設計していたものを非同期APIで作り直したので、内部的には大幅なコードの書き換えを伴うことになりましたが、差分はAAP本体とaap-juceのモジュールのレベルで吸収しているので、移植したアプリケーションのレベルでは書き換えが必要ないところまで収まりました(別途ヘッダファイルのパスの統一などを施しているので、AAP 0.6.xから0.7.0へのアップグレードでは書き換えが必要になっています)。

GitHub Sponsors set up

これは去年の話なのですが、わたしが一時期アプリ開発をちょっとだけ(本当にちょっとだけ)手伝っていたサンプラーアプリがあって、もともとはその開発者がAAPに興味をもって連絡を受けてやっていました。AAPは当時まだJUCEのエフェクトプラグインの移植が使い物にならなくて、「まだ…その時ではない…」と言っていたのでした。サンプラーでエフェクトプラグインが適用できないんじゃ利用機会が無いですし。

それで今は特に一緒にやっていないのですが、そのコミュニティで開発者にAAPを紹介されたという人がコンタクトしてきて、sponsorさせてほしいと申し出をいただけたので、ありがたい2〜と思いながらやりかけで放置していたGitHub sponsorsを設定していました。スポンサープログラムを始めるといろいろサポートを考えないと…となるところですが、あまり気負わずに運用していこうと思います。donationwareとして始めてしまうと仕事見つけづらくなるかな〜とか考えてしまいますし。

WeMove, ADC21動画鑑賞会

しばらく前からMusic Hackspaceというオンラインセミナーを運営しているところで無料のセミナーがあるとたまに参加しているのですが(最初はAndroid Audioだったような気がする)、最近#WeMoveというWomen in Audioのコミュニティと連携して25回の無料セミナーを毎週のように開催するという狂気のような企画があるので(言い方…!)たまに見ています。off-siteではUKの夕方にやっているのでリアルタイムで見られる人はだいぶ限られると思いますが、録画も上がっているので興味がある人はチェックしてみるといいかもしれません。

musichackspace.org

あと最近ようやく昨年のaudiodevcon 21の動画が上がってきています。1週間に1本みたいな感じでちょろちょろと出ていて、このペースでいつ終わるんだろう…?となっていたのですが、最新の動画を最後まで見ていて、見慣れない他のADC21動画へのリンクが出てきたので「あれ?」と思いながらクリックしたら、何やら非公開リストにすでに大量の動画が上がっているらしいことがわかりましたw そういうわけで、そのへんの動画を見ながら勉強する動画鑑賞会を企画しています。来週1度やってみる予定なので興味のある方はお気軽にどうぞ(2020年にやったMusic Tech Meetupのメンバーの承諾のもとこのグループで公開しています)。

music-tech.connpass.com

その他

2〜3月には他にもちょいちょい試したことがあるのですが、今のところボツネタばかりです。最近のボツネタだとBYODを移植しようとしましたが、全く動いていないです。うまくいかなかったVialFX(謎)とかもあるし、まあそういうこともあるよね〜という感じで草の生えていないgithubを眺めています。

4月の予定

native hosting APIの書き直しがざっくり終わったので、3月に公開されたAndroid 13 DP2Android MIDI APIMIDI 2.0対応でもいじって遊んでみたいという気持ちがあります。AAPのMIDI2対応もテコ入れし直しの機運ですね。

4月にはM3 2022春が開催される予定ですが、今回はサークル申し込みしませんでした。今回は入場規制がないので、まだ安全に開催できるのかちょっと未知数だなと思っていることが正直あります(特に音楽関係はソフトウェア業界と違ってリアル空間で生きている人が多いこともあってかそこそこルーズなので…)。せっかく休みにしているので、この機会(?)にAAPの作業を進めておきたいなと思っています。

JUCE on Android : fixing ClassNotFoundException on non-main thread

調査の端緒

JUCE on Androidには、メインスレッド以外ではJNI経由でAndroid frameworkとJUCEのクラス以外でまともにJNI呼び出しが行えないという問題が存在する。自分が初めての発見者というわけではないようで、JUCE forumに同様の報告がある。

forum.juce.com

これは(詳しい分析はここではすっ飛ばして)juce::ThreadAndroid NDKのドキュメントに沿ってjava.lang.Threadを適切に生成せずに処理を開始してしまうことに起因する。

https://developer.android.com/training/articles/perf-jni#threads

It's usually best to use Thread.start() to create any thread that needs to call in to Java code. Doing so will ensure that you have sufficient stack space, that you're in the correct ThreadGroup, and that you're using the same ClassLoader as your Java code. It's also easier to set the thread's name for debugging in Java than from native code (see pthread_setname_np() if you have a pthread_t or thread_t, and std::thread::native_handle() if you have a std::thread and want a pthread_t).

いったん処理を開始してしまうと、後からjava.lang.ThreadsetContextClassLoader()で適切なClassLoaderを設定することができなくなる。だからjuce::Threadの動作開始前(pthread_create()の呼び出し前)に対処しておく必要がある。

Android/Dalvik/ART編

OboeのオーディオスレッドでもJNI呼び出しが失敗する

ここまで調べて気がついたが、このような問題はJUCEのみで生じるわけではない。たとえばOboeはネイティブ(C++)オーディオAPIであり、AudioStreamでコールバックスレッドからonAudioReady()を呼び出す仕組みになっている。このコールバックスレッドはOpenSLESあるいはAAudioが内部的に生成していて、その実体はpthread_create()で生成されている。ここにはJNI・JVMが一切絡んでこないので、後からJNIEnv*JavaVM::AttachCurrentThread()で取得できたとしても、そのスレッドに対応するjava.lang.ThreadにはClassLoaderが適切に設定されていないのではないか。

この仮説に基づいてひとつ実験してみた。Oboeのhello-oboeサンプルに手を加えて、JNIEnv*を取得してFindClass("androidx/constraintlayout/widget/ConstraintLayout")を2箇所で呼び出してみた。ひとつはJNI呼び出しを実装する関数の中で、もうひとつはOboeのonAudioReady()コールバック実装の中である。わざわざConstraintLayoutクラスを指定しているのは、これがclasses.dex(いわゆるmain dex)に含まれていないであろうという前提に基づいている。(d8が同一のclasses.dexにまとめるような動作になっていたら必ずしも成立しないが。)

https://gist.github.com/atsushieno/8e6b0bc82005cdeef65254f9e8bcec1b

果たして予想通り、JNI呼び出しの中ではFindClass()はnon-nullなjclassを返し、オーディオコールバックの中ではnullptrを返した。オーディオコールバックスレッドからJNIをまともに使うのは無理そうだ。

Oboeとしてはこれは想定されている動作であって、公式のgithub discussionsにもそのような議論がある。

github.com

実際的な意味では、オーディオコールバックはリアルタイムに処理が行われることが期待されている。JNIで操作されるART VMはリアルタイムセーフではないので、ARTに何らかの動作を期待するのであれば、atomic operationを使ったnon-thread-localな共有メモリの読み書きなど、何らかの間接的な手段が必要になりそうだ。

これはもちろんOboeが暗黙的に想定するリアルタイムのオーディオスレッドという文脈だからJNI非サポートが正当化できるのであり、non-realtimeなjuce::ThreadなどでJNIをサポートできないことを正当化することはできない。

AAudioのrealtime audio threadはpthread

調べた順序は書いた順序と逆になるが、OboeというかAAudioの中でコールバックスレッドがどのように作られているか、JUCEの問題と性質が同じなのではないかと思って調べてみた。これは、概ねpthread_create()から逆引きして突き止めた。ここではcs.android.comのシンボル参照を辿れるCall Hierarchyという機能が便利だ。

pthread_create()を呼び出すcreateThread_l()のCall Hierarchy この検索結果はAAudioのソースを指している。OboeはOpenSLESとAAudioへのアクセスを共通化する、ある意味(非常に狭い)クロスプラットフォームAPIであり、その実体を追及する意義はあまり無い。AAudioだけ追及すれば概ね足りるので、OpenSLESのほうは追及していない。

https://cs.android.com/android/platform/superproject/+/master:frameworks/av/media/libaaudio/src/core/AudioStream.cpp;l=476;bpv=0;bpt=0

ちなみにほぼ余談だけど、このスクショのようにコールグラフを追ってみた感じ、AAudioの実装はAAudioServiceで、AIDLで規定されたプロトコルに沿って操作しているようだ。BinderはAndroid 8.0でリアルタイム処理も実現できるように改良されていて、オーディオ処理でも使えるようになっている(ただし/dev/binderには開放されていないようだ)。中にはframeworkのコードなのにOboeServiceなどという名前も使われていたりして(frameworkのほうはAAudioが適切なはず)、歴史が垣間見える。

https://cs.android.com/android/platform/superproject/+/master:frameworks/av/services/oboeservice/AAudioServiceStreamBase.cpp

解決策: java.lang.Threadを使う

冒頭で引用したAndroid NDKのドキュメントにあった通り、java.lang.Threadを使うのが正解だとしたら、JNIに正しく踏み込めるような処理をC++のnon-mainスレッドで行いたい場合は、java.lang.Threadを生成して、そのAPIを操作するスタイルで実装するのが正解だろう。実行するRunnerの中で本来スレッドにやらせたかったネイティブはJNI経由で呼び出せるし、もし必要があればその処理の前にpthread_self()を呼び出せばそのスレッドのpthread_tハンドルも取得できる。ただ、java.lang.Thread側にも固有の状態変数が存在しているので、java.lang.ThreadAPI経由で操作するほうが安全ではある(たとえばAndroidのojluni実装の場合、setPriority()ではネイティブ設定の他にjavaフィールドの値も設定するし、getPriority()ではそのフィールドの値が返される)。

もっとも、スレッドの中止に関しては、Androidに関してはpthread_cancen()が存在しないので、java.lang.Thread.stop()もまともに実装されておらず、これに関して心配する意味は無い(!) スレッドに渡す処理のループ条件を適切に管理して「正常終了」させるのが正しい実装アプローチだ。

pthread_create()でスレッドのpthread_tを取得してから、それをもとにjava.lang.Threadインスタンスを生成する方法は無い。

java.lang.ThreadはどこでClassLoaderを正しく設定しているのか

JUCEもAAudioもなまのpthreadだからClassLoaderが設定できていないらしいことはわかったが、ではどこでどう処置すればjava.lang.Threadは想定通りのClassLoaderが設定できるのだろうか。

先に結論だけ書くと、実のところjava.lang.ThreadsetContextClassLoader()はいつでも呼び出し可能になっている。start()の時点で(のみ)その設定値が参照され、以降は単に無視されるということだろう。そう理解して深くは追及していない。

もう少し技術調査したい人向けにいくつか情報を補足しておく(今回の調査の目的からは外れるので自分では深入りしていない):

  • 正しいClassLoaderが設定されていないと、apkに含まれるclasses*.dex(classes.dex, classes1.dex, classes3.dex, ...)を期待通りにロードしてclassを解決してくれない。これはどうやらARTでも変わらない。(getSystemClassLoader()については後で言及)
  • java.*API実装は、Android 24以降はApache Harmonyからopenjdk libcoreベースの実装に切り替わっており、Android固有の部分はojluniというART向けの実装と組み合わされている部分が多い。
  • JNIにおいてClassLoaderはthread localの領域に保存される情報なので、スレッドごとにこれを保持しておく必要がある。
  • JNIにおける各種呼び出しはJNIEnvを通じて行うが、これはJNIのエントリーポイントの実装などで引数として渡されでもしない限りは、JavaVM::GetEnv()なりJavaVM::AttachCurrentThread()なりを使用して、スレッド別に取得(・生成)しなければならない存在だ。
  • AttachCurrentThread()呼び出し時にそのネイティブスレッドに対応するjava.lang.Threadが存在しない場合は生成される。この時点で生成されるjava.lang.Threadで名前から参照解決できるクラスは限られている。java.lang.*などのランタイム型は解決できそうだが、少なくともapk内に複数あるclasses*.dexの全てをロードして解決してはくれない。ARTなのでClassLoader.getSystemClassLoader()で返されるClassLoaderが複数のclasses*.dexに対応している、というわけではなさそうだ。
    • system ClassLoaderは素朴なPathClassLoaderで(parentはBootClassLoader)。mainスレッドで返されるClassLoaderも非mainスレッドで返されるClassLoaderも違いはない(同一インスタンスかどうかは未確認)。

3/14追記: 「mainスレッドで返されるClassLoaderも非mainスレッドで返されるClassLoaderも違いはない」と書いたが、そもそもgetSystemClassLoader()で返されるClassLoaderはdex分割に対応した完全体のClassLoaderではないようだ。試しにJava.initialiseJUCE()を呼び出す部分(Projucerが生成するJuceAppでもいいし、自作のコードでもいい)で次のようなコードを実行してみよう(Kotlin):

        javaClass.classLoader.loadClass("com.rmsl.juce.Java")
        ClassLoader.getSystemClassLoader().loadClass("com.rmsl.juce.Java")
        com.rmsl.juce.Java.initialiseJUCE(context.applicationContext)

実行してみると、2行目でClassNotFoundExceptionになることだろう。これは、system ClassLoaderでは不十分だということを示している。

JUCE編

JNIClassBaseの概要: Java Interopの必要性

JUCEでは内部的にAndroid APIと相互運用しなければならない部分が少なくない。たとえばjuce_audio_devicesmidi_ioAPIを実装するにはandroid.media.midiを使う必要がある(Native MIDI APIはminSdkVersion 29になるのでJUCEのようなフレームワークが採用できるものではない)。そもそもjuce_gui_basicsが利用するjuce_eventsのアプリケーションループもandroid.app.Activityandroid.app.Application.ActivityLifecycleCallbacksAPIを使わないと操作できない。

JNIを利用すれば、少なくともmainスレッドから有効なClassLoaderが提供されている範囲では、これらを実現することは可能だが、毎回JNIのboilerplate codeを書くのはしんどいので、JUCEでは内部実装用にJava APIに対応するJNIクラスをマクロで短いコードで定義・利用できるような仕組みが作り込まれている。これはJNIClassBaseというC++のコードを用いて実装されている。この仕組みを使うと、AndroidフレームワークJava APIの仕組みの上でJNI経由で動作するC++コードを、比較的シームレスに作成できるようになっている。

これはJUCEの外側でも利用できないわけではないが、外部向けに作られたものではないので(少なくともAPIリファレンスには出現しない)、利用するなら将来的な破壊的変更もあり得るという認識が前提になる。

もうひとつ、JUCEではC++Javaのインターフェースやクラスのメソッドのオーバーライドを実装しなければならない場面がある。この仕組みはそれなりに高度で、やっていることはAndroidと他言語のバインディングとあまり変わらない。Xamarin.AndroidJava Binding、React Nativeのbridge、FlutterのPlatform Channel、そういった機構の内部を知っていれば理解しやすいだろう。バインディング自動化機構は無いし、様々な部分を手書きで実装する必要があるので、最小限のランタイムという感じだ。

ランタイムの中心となる実装はjuce_core/native/juce_android_JNIHelpers.hおよび.cppに定義されている。

JNIClassBaseを使用したJavaクラス(のproxy)の定義

JUCEのC++コードで操作されるJavaオブジェクト(のうち、このバインディング機構で制御されるもの)は、そのクラス定義をJNIClassBaseで宣言されている。DECLARE_JNI_CLASSDECLARE_JNI_CLASS_WITH_MIN_SDKDECLARE_JNI_CLASS_WITH_BYTECODEといったマクロを利用して宣言することになる(これらの違いは後述するが、どれも全て最後のやつに行き着く)。

juce_android_JNIHelpers.hで定義されているJavaClassLoaderクラスを例に、いくつかのポイントを箇条書きで説明しよう:

#define JNI_CLASS_MEMBERS(METHOD, STATICMETHOD, FIELD, STATICFIELD, CALLBACK) \  
 METHOD       (findClass, "findClass", "(Ljava/lang/String;)Ljava/lang/Class;") \  
 STATICMETHOD (getSystemClassLoader, "getSystemClassLoader", "()Ljava/lang/ClassLoader;")  
  
  DECLARE_JNI_CLASS (JavaClassLoader, "java/lang/ClassLoader")  
#undef JNI_CLASS_MEMBERS
  • DECLARE_JNI_CLASSマクロの最初の引数は、定義されるクラスとその名前で定義されるstaticなグローバル変数の名前になる。
  • メンバーの定義にはJNI_CLASS_MEMBERSという名前のマクロが使われる。このマクロはその内容自体が現在定義しようとしているクラスのメンバーを列挙するかたちで「定義」し、DECLARE_JNI_CLASS_*マクロからメンバー定義するときに呼び出されるもので、このマクロによる定義・宣言が済み次第#undefで消される(消すべきものだ)。
  • メンバー定義は、フィールドとメソッド、それからコールバック関数の類がstaticとnon-staticで別々に定義される。JNI_CLASS_MEMBERSを実際に呼び出すのはDECLARE_JNI_CLASS_WITH_BYTECODEマクロであり、METHODSTATICMETHODといったマクロ引数の実際の内容はこのマクロから与えられるので、このマクロを利用する側が気にする必要はあまりない。マクロなので内容のチェックは期待できない。
  • これはjava.lang.ClassLoaderクラスのインスタンスを操作するときに、JavaClassLoader.getSystemClassLoaderJNIEnv::CallStaticObjectMethod()の引数に指定するときに有用だ。

JNIClassBaseのBLOB

JNIClassBaseではバイトコードuint8[]定数のBLOBで保持していることがある。

static const uint8 invocationHandleByteCode[] =  
{31,139,8,8,215,115,161,94,...,12,5,0,0,0,0};  
  
(..)

DECLARE_JNI_CLASS_WITH_BYTECODE (JuceInvocationHandler,
   "com/roli/juce/JuceInvocationHandler", 10, invocationHandleByteCode, sizeof (invocationHandleByteCode))

これは実はdexバイトコードを抜き出してgzip圧縮したもので、これが指定されている場合、JNIClassBaseInMemoryDexClassLoaderあるいはDexClassLoaderを使ってこの配列をClassとしてロードする(InMemoryDexClassLoaderを使うか使わないかは、実行時のSDK versionで決まり、in memoryでない場合は一時ファイルに出力する)。どのBLOBにも、対応するjavaソースがJUCEのソースツリーに含まれているはずで、ソースコードの隠蔽などを目的としているわけではない(はずだ)。

(非Android開発者向けに多少入門的な話を書くと、Android*.javaのソースをコンパイルすることはあっても、*.classJavaバイトコードはapk/aabのビルド時にclasses*.dexというDalvikバイトコードに変換してあり、実行時にClassLoaderでロードできるのも*.classファイルではなく*.dexだけだ。)

独自のdexバイトコードは、JUCEで何らかのJavaクラスを提供しなければならない場面で、*.javaソースコードAndroid Studio / Gradleでビルドする代わりに、C++コンパイルだけで完結できるように作られているようだ。その技術選択にはかなり議論の余地があるが、現状ではそうなっている。

今回の調査の成果のひとつとして、BLOBを全て削り落として、代わりにbuild.gradle(.kts)でJUCEの*.javaソースから必要なコードをコンパイルする方式のパッチを作った。 https://gist.github.com/atsushieno/1ab9f9d4a1f9119db1d2ac88b4257bcb

BLOBを使用する代わりにJavaソースコードをビルドするアプローチにした場合、build.gradle(.kts)上でそれぞれのBLOBに相当するJavaソースをコンパイル対象として明示的に指定する必要がある。どのモジュールを選択しているときにどのディレクトリを追加する、といった処理をProjucerやCMakeモジュールに追加するか、あるいは全てのモジュールのJavaコードを全てコンパイルする必要がある。後者のほうがずっとシンプルだが、ソースコードによっては追加の依存モジュールが必要になる。たとえばjuce_product_unlockingではfirebaseのモジュールをdependenciesに追加する必要がある。これは割とニッチなモジュールで、通常は必要ない。必要ないものをdependenciesに常に指定するのはあまり健全ではない。

いや、そもそもJUCEは今でもCMakeをAndroidでサポートしていないんだった。CMakeを使えるようにするには、自前でAndroidプロジェクト全体を用意するアプローチになる。それであれば、使用するJUCEモジュールに合わせてbuild.gradle(.kts)を調整するのは全く難しくない。それすらやりたくないということであれば、Projucerのbuild.gradle生成をちょっと工夫させるだけでも良いだろう。

もちろん、BLOBを利用するのはビルドパフォーマンスの向上のためとは言い難い。これらBLOBがあろうと無かろうとJuceApp.javaなどが存在する以上、javaコードのコンパイルは発生するし、ほとんどの場合そのコンパイルは一度だけしか起こらない(何しろコードが書き換わらないので)。

JNIClassBaseの実行時利用

JNIClassBaseはcom.rmsl.juce.JUCE.initialiseJUCE()あるいはThread::initialiseJUCE()が呼び出されたときに(前者は後者を呼び出すので実質的に同じ)、JNIClassBase::getClasses()関数のstatic storageにあるArray<JNIClassBase> classesに含まれている全てのクラスをそれぞれのinitialise()でロードする(DECLARE_JNI_CLASS_*マクロを使うと自動的にこのArrayに登録される)。この時点でもしバイトコードが定義されていたらロードされ、メソッドの引数型など依存クラスがあれば全て芋づる式にロードされる(はず)。

JNIClassBase::initialise()java.lang.Classをロードする手順はいくつか条件分岐があり多少コードが長いが、ポイントだけ押さえると、dex BLOBを含むJNIClassBaseのロードには、InMemoryDexClassLoaderなどが使用される。実際に行われているのはJNIでClassLoaderによるクラスのロード(の試行)程度の内容だ。

JUCEで宣言されていないクラスをJNIClassBaseで宣言する

#include <juce_core/juce_android_JNIHelpers.h>を書けばどこでも宣言できるはずだ。正しく記述できなければ実行時エラーになるところまではできた。

今回の調査の一環で、pthreadの代わりにjava.lang.Threadを使ってjuce::Threadを書き直したパッチを作成したが、この中でJavaLangThreadというクラスを独自に定義している。これは最終的にjuce_android_JNIHelpers.hに直接追加したが(Threadの実装はjuce_coreにあるため)、自分のアプリケーションのソースに置いても、シンボルが解決できる限りビルドできるだろう。

AndroidInterfaceImplementer

さて、この節の冒頭で言及したもうひとつの課題として、JUCEのJNI interopではJavaのインターフェースやクラスのメソッドのオーバーライドをC++で実装しなければならない状況がある。たとえばjava.lang.Threadのコンストラクタにはjava.lang.Runnableを渡す必要があるが、自分でこのインターフェースを実装してrun()でJUCE/C++のコードを実行したい場合は、Runnableの実装が必要になる。

3/15追記: 初出時「インターフェースやクラスのメソッド」と書いたが、抽象クラスの実装として使えるようには見えないので取り消し線を追加した。Xamarin.AndroidでもXxxImplementerとXxxInvokerは別物だ。

このような場合に、足りないJava実装をどうやって提供するか、いくつか方法が考えられるが、JUCEではjava.lang.reflect.Proxyjava.lang.reflect.InvocationHandlerという仕組みを活用して、Javaコードのビルド時生成を一切行わないかたちで実現している。

Proxyは、Javaオブジェクトとして振る舞うが、名前と引数型からメソッドを呼び出すときに、Proxy生成時に渡されたInvocationHandlerの実装のinvoke()メソッドを使う:

abstract Object invoke(Object proxy, Method method, Array<Object> args);

このInvocationHandlerは、java.lang.reflect.Proxy.newProxyInstance()に引数として渡される。

public static Object newProxyInstance (
    ClassLoader loader, Class[]<?> interfaces, InvocationHandler h);

これで生成されたObjectは、メソッド呼び出しをInvocationHandlerによって解決するので、Javaインターフェースの実装として機能するというわけだ。

JUCEの具体的な実装としては、JUCEInvocationHandlerというInvocationHandlerの実装が、JavaインターフェースをC++で実装したクラスで用いられている。javaコードで実装されていて、例によってdexバイトコードjuce_android_JNIHelpers.cppに埋め込まれている。JUCEInvocationHandlerは実装の詳細だが、invoke()の中でネイティブコードにコールバックするだけなので、われわれこの機構のユーザーがこのクラスを意識する必要は、通常はない(ただクラッシュ時にstacktraceに出現するので気になるだろう)。

このソースファイルの中には、CreateJavaInterface()という関数が定義されている。この関数がjava.lang.reflect.Proxy.newProxyInstance()を(JNI経由で)呼び出してProxyオブジェクトを返す。返されたjobjectのProxyはそのままJNIで利用できるというわけだ。CreateJavaInterface()にはいくつかオーバーロードがあるが、ひとつだけ引用しよう:

LocalRef<jobject> CreateJavaInterface (AndroidInterfaceImplementer* implementer,  
  const StringArray& interfaceNames,  
  LocalRef<jobject> subclass)

ここで初出のAndroidInterfaceImplementerクラスは、JavaインターフェースのC++実装で使われる基底クラスになる。この仕組みを利用してJavaのabstractメソッドを実装するときは、このクラスのinvoke()関数をオーバーライドする。たとえばjuce_android_Files.cppにあるMediaScannerConnectionClientクラスがどう実装されているか引用しよう:

class MediaScannerConnectionClient : public AndroidInterfaceImplementer
{
    (...)
    jobject invoke (jobject proxy, jobject method, jobjectArray args) override
    {
        auto* env = getEnv();

        auto methodName = juceString ((jstring) env->CallObjectMethod (method, JavaMethod.getName));

        if (methodName == "onMediaScannerConnected")
        {
            onMediaScannerConnected();
            return nullptr;
        }
        else if (methodName == "onScanCompleted")
        {
            onScanCompleted();
            return nullptr;
        }

        return AndroidInterfaceImplementer::invoke (proxy, method, args);
    }
};

このinvoke()java.lang.reflect.Proxy.invoke()の実装とほぼ同じ役割を担っている。ここでメソッド呼び出しを自前で解決できるので、この機構のユーザーはC++コードを書くだけでJavaインターフェースを実装できるというわけだ。

まとめ: java.lang.ThreadでJUCE::Threadの実装を差し替える

さてそろそろまとめ…というか本題に戻ろう。今回の調査の最終的な目的は、JUCEのnon-mainスレッドからでもJNIを問題なく呼び出せるようにコードを作り変えることにある。

Android/Dalvik/ART編でざっと見てきた通り、C++コードでpthread_create()を呼んでしまうと、それをjava.lang.Threadに紐づけ「かつ」ClassLoaderを正しく設定する、ということは出来なそうだ。それであれば、pthread_create()している部分で代わりにjava.lang.Threadを生成し、そのRunnable引数の中で先のAndroidInterfaceImplementerによるProxyを使って、ネイティブコードの中でpthread_self()を呼び出してpthread_idを取得することで、正しいClassLoaderをもつスレッドが生成できるはずだ。

という考え方に基づいてJUCEのパッチを作成した: https://gist.github.com/atsushieno/5e28f5a0a319dc24fa8a7579f826b3aa

まだ未完成だが(setPriority()などが実装されていない)、とりあえずはこれでClassNotFoundExceptionは発生しなくなった。

実際にはこれは2つのパッチの組み合わせになっている

後者は単にBLOBローダーの仕組みを取っ払ったパッチなので、本当は前者だけで動いてほしいのだけど、まだ未調査の原因でSEGVになる。この仕組みを取っ払った以上、build.gradleを書き換える作業も必要になるのだけど、そこまでは出来ていないし自分のAudioPluginHostの移植でも自動書き換え機構が必要になるかどうかわからないのでProjucerなどは何も変更しておらず、upstreamに取り込める状態ではない(JUCEチームはいずれにせよ基本的にパッチを受け付けないので、彼らが自分で実装する必要があるだろう)。

3/14追記: Android/Dalvik/ART編の追記に繋がる話だが、JUCEではgetSystemClassLoader()を使っている部分がある。これは上の追記部分で説明した通りダメなやつなので、これを使っている部分で適切なClassLoaderを使うことで、BLOBローダーの仕組みを残したままバグを解消できた。

これと上の1つ目のパッチならば、プロジェクト生成で抜本的な変更を必要としないので、JUCEにも当てやすいパッチになるだろう。

2月の活動記録 (2022)

先月ちらっと書いたのですが、今月はAndroidオーディオプラグインの抜本的な仕切り直しを計画していました。

今年は完全個人プロジェクトを卒業して他の人にも使ってもらえるようなところまで持っていきたいという気持ちがあります。去年も1人で続けていましたが、だんだん個人の開発リソースでは足りない/主要な開発作業が進められなくなりつつあります(LV2とJUCEのプラグイン移植をAAPの最新版に追従させるだけでもそれなりの仕事になるのです)。Androidオーディオアプリの開発者の人たち(希少種!)からたまに「手伝えることがあれば言ってくれ」と言ってもらえてありがたいのですが、他人を巻き込むためには、既存コードはあまりにもスパゲッティすぎるのと設計がぐちゃぐちゃなのと、だんだん開発を進めるうえでブレーキになりつつあったので、一度やっておくべきと思った次第です。

仕切り直しを「計画していた」と過去形になっているのは、とりあえず現時点ではプラグインフレームワークとしての互換性が維持された範囲でリファクタリングを行っているためです。一番過激な仕切り直し構想では、新規リポジトリ(未公開)にGUI統合なども考慮した新しいモジュール構造を作って既存コードをビルドできるところまでやっていました。ただこれは現在リファクタリング中のソースコードと大きく乖離してしまっていて、再利用されることはないでしょう。

AudioPluginMidiDeviceServiceの安定化

AAPをオーディオプラグインAPIとして他の人に使ってもらえるようにするには、APIがまだまだ安定しませんし、実のところリアルタイムで安定的に使えるほど実装の品質も良くありません。そういう現状でAAPを使ってもらえるようにするには、現状2つの方向性があると思っています。

  • 仮想MIDIバイスとして利用できるようにする: これならAndroid MIDI APIの範囲でしかいじられないので、APIの安定性を気にする必要がほぼありません。できればプリセットまでサポートしたいところです。
  • サンプラー等で静的にエフェクトをかけられるようにする: リアルタイムで厳しくても静的なら問題ないだろうし、それでも一定の需要はあるだろうという考えです。基本的なAPI安定性が必要になるのと、UI無しでどこまで利用可能性があるのかやや未知数なことがあります(これもプリセット次第かもしれない)。GuitarMLみたいにモバイルでのリアルタイム利用が現実的でなさそうな分野では有用かもしれません(まだ移植したことがない)。

ただ、MIDIDeviceServiceの実装が、1月時点では非常に不安定で、「他のMIDIバイスに切り替えたら落ちる」「2回インスタンス生成したら落ちる」みたいなレベルだったので、コードの改善が急務でした。現在ではこれらの問題は解決していますが、それはネイティブコードにそれなりに抜本的に手を加えて実現したものです。

ネイティブ実装まわりはしばらく真面目にデバッグする必要がなかったこともあって、開発体験が非常に悪かったのですが(NDKとAndroid Studioの完成度に大きく依存するし…!)、今はデバッグビルド用にAddressSanitizerなどを利用しやすく整備したので、少なくともそこでやる気をフルブーストする必要はなくなりました。本当はAndroid NDKのバージョンをr21からr23に上げたかったのですがNdkBinderまわりのリグレッションに当たってしまい、修正されるまで棚上げです…

理想的な安定化が実現できたかというと、長大なリソースロード時間を要するプラグインなどがまだクラッシュする問題などがあって、他の人に「使ってくれ〜!!」と言うにはまだ片付けるべき作業があると思っています。(ただ先に後述の課題を片付けたいところ…)

aap-lv2-string-machine

去年は雨後の竹林のようにプラグイン移植のリポジトリを生やしていたものですが、今年はなるべく控えめにしようかな…と思っていました。が結局今月もひとつ作ってしまったり…(!?)

AAP-LV2の移植まわりで長らく問題になっているのがdragonfly-reverbの移植がまともに機能しない問題です。オーディオ処理した結果の波形がめちゃくちゃになったり音が何も聞こえなくなったり…。それで、まず問題を切り分ける目的で、dragonfly-reverbが使っているプラグイン開発フレームワークであるDPFを使った他のプラグインとしてstring-machineを移植してみました。2時間とかからずに移植できて問題なく動いたり…

github.com

そんなわけでDPFには問題がないことがわかったので、もう少し別の角度から検証が必要そうです。DPFプラグインはビルドがMakefileなので、どうしてもandroid-native-audio-buildersでバイナリビルドせざるを得ず、Android Studioからデバッグどころかprintfデバッグすらできないのが難儀なところです…

AAP 2022 roadmap and design docs

今月の半ばに、2022年版の開発ロードマップを公開しました。実のところ半分くらい2021年版から引き継いでいますし、1年かけてこれを進めるとか、逆にこれ全部やるのに1年かける予定だとか、そういうものではないです。他にもやることあるだろうし。

github.com

designing new ports and parameters

これで、まずは地味だけど重要な部分として、プラグインのポートとパラメーター(現在存在しないコンセプト)から着手しようと思い、新しいポート設計の構想から練り直したのですが、すでにいくつかissueとして登録してあったのをまとめるだけで割としんどい作業でした。しかも1年以上ずっと残っていたり…

github.com

AAP開発当初は、オーディオプラグインのポートはとりあえずLV2と同じ方式にしておけば良いだろうと考えていました。オーディオプラグインのコードに手を出し始めたのも4年くらい前からだったと思います。それからそれなりに経験を積んで、プラグインのパラメーターは何百もあったりするからポートを何百も作るのではなくパラメーターとしてちゃんと作ったり、設定にはlv2:patchみたいな新しい仕組みでやり取りすべき、みたいなことが分かってきました(この知見自体LV2コミュニティでも割とモダンな知識)。

パラメーター変更を受け付ける手段は、デスクトップのオーディオプラグインフレームワークでもGUIを含むイベントメッセージングの統合部分で課題になります。AAPの場合は(長らく着手できていない)GUI統合がプロセスをまたぐこともあり、またAndroidのServiceとしてクライアント(GUI)から接続することを考えると、接続は1つだけど対象インスタンスもクライアントも複数あって…といったモデルを考えなければなりません。

そんなわけで新しく設計ドキュメントをまとめたわけですが、コレに着手する前にやることがある。そう…

ネイティブAPIの破壊的な整備

まずネイティブAPIがスパゲッティなのをどうにかしないと…となったわけです。スパゲッティになっていることにはいくつか歴史的な理由があるのですが、やはり去年まででいろいろプラグインを移植してみて分かってきたことが増えてきたのと、デスクトップとは根本的に設計が違うものを無理に合わせてきたことが積み上がってのことでしょう。これもとりあえず大掛かりな変更作業になりそうなので、先にドキュメントとしてまとめたのですが、長期的に残したい内容でもなかったのでissueで済ませています。

github.com

MIDIまわりのリファクタリングの際に、1年以上固定していたバージョン0.6.xを0.7.0に上げたところだったので、今のうちにいろいろ破壊的変更を加えてしまおうという気持ちでアグレッシブに手を加えています。ユーザーがいないうちにやってしまおうという感じです。あまりアグレッシブに変更すると自分でも大量の移植コードが追従できなくなるのでやりたくはないのですが、この辺はバランスですね…。この作業は現在進行形でmainにもマージされていないので、月を跨いで継続していく予定です。

1月の活動記録(2022)

2022年最初の活動記録です。といっても実のところ今月は書けるようなことがびっくりするくらい無いですね。というのは、昨年末に書いた話ですが、いじっていたemscriptenまわりのやつとか、他にも何点かあるんですが、ほとんど全滅状態で、あとAndroidオーディオプラグインも、抜本的な仕切り直しを計画しながら現行コードをでかめにリファクタリングしていたりなどして、まだ進行中だし今月ネタになるようなことではないです。

Vital batch-import-wavetablesブランチとopen-vital-resources

今月は割とVitalの音作りで遊びながらソースコードを読み解いたりしていて、それはそれで調べものの結果ちょっとした資料はできつつあるのですが、文章にするほどまとまってもいなくてアウトプットにはならない感じです。

ただ、資料とは別のアウトプットの方向性として、Vitaloidを作ったのと関連して、OSSビルドでも利用可能なプリセット集合を作らないといけないな…という気持ちになっています。Vitalの「プリセット」を構成するのは、.vitalプリセット定義ファイルの他に、.vitaltableというウェーブテーブル定義と.vitallfoというLFO波形定義があり、これらが何一つOSS版に含まれていません。

これでは困るので、少なくともウェーブテーブルくらいはフリーのwavファイルから利用できるようにしてみよう、と思って、いろいろ試行錯誤しました。そもそもウェーブテーブルシンセサイザーにおけるウェーブテーブルの何たるかも知らないところからスタートしたので、たとえば「最大2048サンプルで…」みたいなところから「Serumのclmヘッダに情報が…」みたいな話とかまで習得したレベルです。これをVitalのコードリーディングと合わせて進めました。

それでVitalのフォーラム等で紹介されているフリーリソース(主にSerum用など)をもとに、Vitalの.vitaltableファイルを生成するツールを作ろうと思ったのですが、VitalがWAVファイルをGUI上でインポートした時にやっていることを全て自前で外部のツールから再実装するのは面倒だということに気付いたので(しばらくいろんな言語で試してみてから…)、むしろVitalのソースコードに手を加えてバッチインポート機能(WAVのあるディレクトリを指定してロードしたら全部.vitaltableにしてくれるやつ)を作ろう…となって、結果独自ソースツリーが出来上がりました(コード変更量は大したことないはず):

github.com

これをもとにCC0(現状)で公開されているリソースを取り込んだリポジトリを作ってあります:

github.com

フリーのウェーブテーブルリソースのほうはどう調達したかというと、まずWaveEditというエディタで作成されたウェーブテーブルがCC0で大量に公開されているのを全部取り込んでいます。大量と言っても凄まじい量というわけではないので程よく豊富だと思います。同様にkimurataro.comのFree WavetablesもCC0でWaveEditからいい感じの量で作られているので、これも取り込んであります。

あと、これは手作業で作ったものですが、LFO定義が何一つ存在しないのはさすがに不便極まりないので、直線や三角波、擬似サイン波などの.vitallfoファイルをいくつか作成しました。擬似ランダムなどはまあみんな自作できるでしょう…(適当に作っても良いのですが)。

本当はFactoryにあるBasic Shapesに相当するもの.vitalプリセットなどを作って含めても良いのですが(単純波形の組み合わせでしかない)、key frameをどう配置するか等でそれなりに工夫が効いてくる可能性もあるので、もうちょっと機械的に生成できる何かにするか、独自プリセット素材を作るか、考えてからがいいかなあと思っています。

あとは、ここまで書いてきたことを、もう少し誰にでも分かるように説明する資料を完成させたいところですが、これはいずれ機が熟したら…という感じです。

12月の活動記録(2021)

12月の活動記録です。2021年の〜はめんどくさいのでやめました。毎月書いてるからただの繰り返しになりそうですし。

JzzMidiAccess

atsushieno/ktmidiはKotlin MPPでMIDIバイスに接続できるAPIを提供しています。その実装はプラットフォームごとに、かつOSごとに異なるというある意味地獄絵図のようなマトリックスになる…はずですが、Kotlin/JVMではRtMidiをJNAでアクセスしてWin/Mac/Linuxサポートをまとめて実現していたり(Linux用にはALSA実装も提供しています)、Kotlin/JSとKotlin/NativeについてはMIDIデータ(SMFとMIDI 2.0 UMP)を操作するAPIだけ使えるようにしていました。それでしばらく放置していたのですが、ふとCompose for Webでも遊んでみようと思って、それならその前にKotlin/JSで使えるMIDI Access実装を用意すべきだと考えたのでした。

Kotlin/JS実装で面倒なのは、browser環境とnodejs環境の前提がまるで違うということです。browserは要するにWeb MIDI APIで、nodejsの場合はrtmidiなどを使えば、一応最低限の機能は実現できます。ただ、この面倒な問題はKotlin/JS固有ではなく、少し探してみると、Webでもnodeでも統一的に扱えるAPIを提供しているjzzというライブラリがありました。開発元のJazz-Softは、Web MIDI APIのブラウザサポートがまだChromeにも無かった頃からブラウザプラグインで使っていた人なら覚えている人もいるかもしれませんが、あのブラウザプラグインがまさにJazz-Softです。

あとKotlin/JS、KotlinとJSのinteropがどうなっているのか、明確にドキュメントになっていないっぽい部分も多くていろいろ手探りしなければならないところがあります。たとえばJZZのMidiOutのsend()に渡すarrayはByteArrayでもIntArrayでもなくArray<Int>でないと実行時エラーになる…みたいなことになります。

もうひとつ、これはほぼ偶然なのですが、このタイミングでKotlin/JS実装に手を出したのは割と正解っぽい要素がひとつあって、今月リリースされたkotlinx-coroutines 1.6.0からsuspend funのテストが書けるようになっています。これまで書けなかったというわけです。詳しくはzennに書いています(っていうほど詳しくもないけど)。

zenn.dev

KSPで最速のコード解析・生成を実現する @ アンドロイド・アンサンブル(C99)

2年ぶりにコミケが開催されるというのでTechBoosterからAndroid同人誌の新刊が発行されたのですが、その中でKSPの記事を1本書いています。~Androidの本というかビルドシステムの本みたいになってるというのは内緒。~

techbooster.booth.pm

augeneのSystem.Reflectionを使ったXMLリアライザーの代替をKSPのコードジェネレーターで無理やり置き換えた体験をもとに書いたわけですが、MultiplatformでハマってissueやらKotlin slackやらでフィードバックしつつ解決したビルドのハマりどころに触れたり、そもそも全体的に存在意義が簡単にわかる仕組みではないのでその辺をかみくだいて説明する感じの内容になっています。

MML to MIDI 2.0 to DAW @Music LT & Modernize MML for 2022

12/14にMusic LTというイベントがあって、IoT LTという巨大コミュニティ(昔のAndroidの会が無数の支部の上部組織みたいになってたやつのIoT版)から派生したイベントだったのですが、IoT関係なくてもおkというのでLTで参加させてもらってきました。

iotlt.connpass.com

このスライドに沿ってしゃべっています。

speakerdeck.com

これに続いて(ホントは事前に出したかったのですが)、ひさびさにgithub.ioのほうでModernize MML for 2022という一連の記事を出しています。(1)から(5)まであります(!) 書くのが量的にたいへんだったし日本語ではここでちょいちょい活動記録として書いているので今回は英語のみです。

atsushieno.github.io

Vitaloid

これはいったん書いて出したので改めて書くことはほぼ無いのですが:

atsushieno.hatenablog.com

このスクリーンサイズをどうにかしたいなあと思ってJUCE本体のコード(juce_audio_plugin_clientStandaloneFilterAppあたり)をいろいろいじって試行錯誤しているのですが、この辺はvitalが独自にいじっている部分とかぶっているところでもあって、今のところうまく行ってない感じです。Viewportを独自に追加して内容を大きく表示するくらいはできても、縮尺がおかしかったりポインターイベントの座標が連動しなかったり…

あと昨日ふと思ってこっちでもやってみようと思ったのですがjuce_openglサポートが無かったのでその辺を追加して)動かしてみるところから必要なので、そこからさらにvital独自パッチも取り込んだビルドを作る…というキメラなビルドが必要になるので、ちょっとメンテナンスコスト高いな〜という感じです。まあjuce_openglが動いて出来そうならやると思います(期待値はまあ半々といったところ)。

onwards...

そんなわけで今年はVitaloidまわりをいじりながら年を越すことになると思います。来年はAndroidオーディオプラグインをもう少し進めていきたいですね。ではまた来年。

【サルベージ】 オーディオプラグインフレームワークを設計する

昔からいろいろな思いつきを書き溜めたまま放置してしまって、そのままお蔵入りになることが多いのだけど、これは一度考え方のたたき台的に出しておいたほうが良いかと思ったので公開することにした。書いたのは最後の2段落以外は2020年11月なのだけど、最近CLAPも話題に出てきたのでその流れでも読めるかもしれない。

以下本文。


これは自分が現在開発しているAAP (android-audio-plugin-framework) の設計方針を見直すために書いている。

オーディオプラグインフレームワークの乱立問題

オーディオプラグイン規格をきちんと作り出すのはたぶん簡単ではない。

  • 「仕様が乱立してどれを使うのが最適とも言えないから最強の仕様をひとつ作ろう」の罠にハマる。作り出された新しい規格には誰も乗ってくれないので結局無駄な作業になってしまう。
    • とはいえ、AAPのように「そもそもインプロセスでライブラリをロードできる前提で設計されたオーディオプラグイン規格はどれも使えない」という状況では、新しいものをゼロから設計するしかない。
  • 後方互換性を維持するのが重要だが、オーディオプラグイン規格のトレンドの動きは非常に緩やかで、一般的な開発のトレンドとの乖離が著しい。今の技術的課題のひとつはVST2からVST3への移行だが、VST3がすでに10年以上前の仕様だ(あくまで3.0が10年前なのであり、現在の3系列の最新版は3.7である)。
  • フレームワークの採用のトレンドが緩やかであるにもかかわらず、技術の進歩には追いつかないといけない。10年前に重要なトピックとして存在しなかった技術の例を挙げるなら、MIDI 2.0, BLE MIDI, モバイルプラットフォーム、3Dオーディオなどがある。

オーディオプラグインの実行環境とSDKの分離思考

従来は、オーディオプラグインフレームワークとはランタイムとSDKの両方を曖昧に含む関係だったが、JUCEやWDL/iPlug2、DPFなど、オーディオプラグインフレームワークそのものではなく、オーディオプラグインを開発できるSDKがポピュラーになっている。Carlaのように複数のプラグインフレームワークやファイルフォーマット(sf2/sfzなど)に対応する機構もある(が、まだポピュラーとまではいけない)。新しいプラグインのランタイムも、これらにプラグインバックエンドやホストバックエンドを追加すれば対応できるので、フレームワークが乱立することそのものについての弊害はある程度縮小している。

これを前提として考えると、新しいオーディオプラグインフレームワークを構築するのは、必ずしもそこまで無価値ではない。

初期段階ではそのフレームワークAPIが安定していることよりも、それらの開発フレームワークのサポートが重要であると考えられる。一般的に、長期的に開発されていてポピュラーなSDKAPIが安定している。

安定的なAPIと安定的なABI

プラグイン開発者あるいはホスト開発者として懸念すべきは、「API」の安定性だ。APIとABIの維持に関する各ステークホルダーインセンティブをまとめておく。

  • ホスト開発者が実際に気にするのは(べきは)APIではなくABIのほうだ。プラグインを動的にロードしてそのABIが期待したものと異なっていたら使えない。ホストを開発する際に必要になるAPIは破壊的でないほうが望ましいが、絶対ではない。
  • プラグイン開発者は最新版のプラグインが古いホストでも使えれば十分、という程度にはABIが維持されていないと困る。プラグイン開発時のAPIは破壊的でないほうが望ましいが、絶対ではない。
  • 作曲家・DAWのユーザーは、古いプラグインも古いホストも、新しいプラグインや新しいホストと組み合わせて動作してもらわないと困る。ABIの維持が最も恩恵を受ける層はユーザーである。APIの破壊的変更に関心は無い。
  • 楽曲を演奏するアプリケーションのユーザーはほぼDAWのユーザーと同視できる。

バージョン1.0をリリースしてsemantic versioningを意識する段階になったら、プラグインAPI自体が破壊的変更を要求しないようにしなければならない。もっとも、破壊的変更のニーズは常に存在する。LV2設計者はrun()にオーディオバッファを渡す仕様にせずにconnect_port()でポインタ上の接続を確立してrun()に何も渡さない仕様にしたことを後悔しているが、このようなレベルで非互換問題が生じる(これを生じさせたい)可能性は常にある。VST3の仕様が策定された時に、プラグインがプロセス分離された空間でないとロードできないiOSAndroidのような環境が出現することは想像できなかっただろう。

(AAPではNdkBinderとAIDLの制約上、個別のashmemポインターをParcelFileDescriptorとして送受信せざるを得ないので、LV2で反省しているようなことが実現できるわけではないし、JUCEサポートにおいてはashmemポインターが動かないことを前提としている。)

現状AAPは破壊的変更の過ちを犯すルートにある(APIの追加が破壊的変更になっている)。VST-MAのCOMライクなクエリーインターフェースのほうが賢いこともある。LV2でこれをやろうと思ったら拡張を使うしか無いし、全てのホストに拡張に対応させるしかない。

一方でVST3のクエリーインターフェースはまだるっこしいのでVST3そのものが採用されない、みたいな側面はあった。このあたりはABIが課題になるような低レベルではなく、一段上の、APIの破壊的変更があり得るeasing API SDKを用意することで対応するのが適切かもしれない。

GUIとの連動・分離

WindowsではWin32 APIMacOSではCocoaでほぼ統一できるが(ホントかな…CarbonとCocoaを同一視してるレベルみたいな気もしてきた。まあとりあえずいいか)、LinuxデスクトップではGtk2/Gtk3/Qt4/Qt5で乱立していて、しかも相互運用性が無かった。現在はX11を使いそれ以上のフレームワークを使わないのがトレンドになっている。結果的に、オーディオプラグインGUIフレームワークはプラットフォーム標準やUI技術から乖離した、貧弱なゲームUIフレームワークに近いものになってしまっている。

これは伝統的なオーディオプラグインの立ち位置であり、モダンなUIに合わせてプラグイン機構を構築できる可能性も十分にある。そのためにはプラグイン機構とUI機構の十分な分離が必要になる。これを強制的に実現できているのはLV2のみだ。LV2の場合、オーディオプラグインコード(classなど)を直接参照することもできないため、ポート経由でUIの更新を反映するしかないため、この分離点をより強力に(AOPのように)制御できる。AUVST(あるいはそれを前提にしたJUCE)ではこれが自然にはできない。

とはいえ、LV2でもGUIX11の貧弱なUIフレームワークしか使えない事態に変わりはない。LinuxGUIで相互運用性がないというのは、具体的にはGUIのmain loopの設計の相違で複数のGUIフレームワークが両立し得ないという状態だ。X11を使っているのは最大公約数としての消極的な対応でしかない。真面目に解決するなら、プロセスレベルで分離した上で、UIをホストとの間で制御する仕組みを構築する必要がある。

プロセスを分離するとパフォーマンスに影響が出るが、そもそもUIスレッドとオーディオスレッドは分離していなければならないし、UIスレッドにリアルタイム要件は無い。UIからオーディオパラメーターを操作するときはアトミックであることが求められるし、オーディオのプロパティはUIプロセスからオーディオプロセスへのリクエスプロトコルによってクエリできればよい。