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プロセスからオーディオプロセスへのリクエスプロトコルによってクエリできればよい。

VitalをAndroidで動かす

JUCE Advent Calendar 2021、24日目も空いていたので滑り込ませてみました。今年4本目じゃん。

最近Vitaloidという名前でVitalをAndroidに移植して動かしてみたプロジェクトを作って遊んでいるので、どうすればそんなことができたのか、今何が出来るのか、何が出来ないのかについてまとめます。一応GitHub Actionsのbuild artifactにapkも含まれているので、興味がある人はいじってみてください(no supportです)。

github.com

Vitalについて

Vitalは2020年末にGPLv3でリリースされたウェーブテーブルシンセサイザーで、JUCEで作られています。

vital.audio

とにかく機能豊富で、わたしも細かく把握していないのですが、YouTubeのLOBOTICS CHANNELで詳しく紹介されているので、コレを見ながらコードを読んだりしています。

www.youtube.com

今回は使い方の話は主な話題ではないので、その説明に文章を割くつもりはありませんが、かといって細かい機能の話に踏み込む場面が無いわけではないので、説明無く登場する機能については適宜この辺を参考にしてもらえればと思います。

Vitalのソースコードの特色

VitalはProjucerを使ってプロジェクトファイルを生成してビルドするタイプの古典的なJUCEアプリケーションです。ただ、いくつかの点で特殊な構成になっています。また、バイナリディストリビューションをインストールした場合とソースからビルドした場合で、根本的に違う部分もあります。

  • JUCE 6.0.5近辺のmodulesのソースコードに独自に手を加えたものがリポジトリに含まれており、JUCE公式のソースに単純に差し替えてもビルドできません。その代わり(?)LV2サポートなども含まれています。
  • 一般的にはProjucerのプロジェクトでは、1つの.jucerファイルで単独実行アプリケーションとプラグインについて別々にプロジェクトを生成しますが、Vitalの場合はstandaloneとpluginで別々のvital.jucerが使われており、内容もそれなりに異なっています。
  • サーバーと連携する部分でFirebaseサポートなどが使われており、これはそのままではビルドできません(そしてOSSビルドと相性が悪い部分でもあります)。
  • Vitalのソースツリーにはデフォルトで利用可能なプリセットが何も含まれていません。Vitalはこの部分で商品価値を出しているわけです。音楽フリーソフトウェア界隈(というのが適切そうなコミュニティ群があります)ではOSSで利用可能なプリセットバンクを作ろうとしている人がたまにいるようです。
  • GUI部分は基本的にOpenGLで実装されています。分かる人向けに書くと、class OpenGlDeviceSelector : public OpenGlAutoImageComponent<AudioDeviceSelectorComponent> みたいなコードも出てくるので、それなりにガチのOpenGL実装っぽいふいんきを感じます(自分がOpenGL全くやらないマンなので妥当な評価なのかわからない)。
  • SIMDNEONで最適化されていてARM32ではビルドできません。(arm64の命令が使われている)

いずれにせよ、Androidビルドは含まれていないので、独自にセットアップする必要があります。

Androidビルドをjucerファイルに追加する

一般的なJUCEアプリケーション開発者にとっては、Androidをサポートする唯一の方法はProjucerでプロジェクトを生成するやり方です。自分の場合はCMakeで構成されたプロジェクトでも対応できるように、ビルドを把握しています。

atsushieno.hatenablog.com

今回は元ネタがProjucerで作られているので、Projucerでやっています。もっとも、自分の場合、Android移植は自作オーディオプラグイン機構のためにやっているので(Vitaloidもそう)、そのために作った他のプロジェクトの.jucerファイルから<ANDROIDSTUDIO>セクションなどをコピペしてきて、そこにVital固有のオプションなどを追加しています。ちなみに、現在のリポジトリでは、コピペ元の.jucerは、ソースツリー中のpluginからコピーしてきたvital.jucerを加工していますが、オーディオプラグインと無関係にビルドするのであればstandaloneを加工するほうがだいぶ簡単です(理由は後述)。

.jucerの中でFirebaseサポートのために追加されているライブラリやヘッダがありますが、これらが有効になっているとビルドできません。これらは<ANDROIDSTUDIO>要素のextraDefs属性でREQUIRE_AUTH=0&#10;NO_AUTH=1&#10"を追加して対応します。またこの他にOPENGL_ES=1&#10;BUILD_DATE=2021_12_00_00_00&#10;(あるいは他の適当な日付の文字列)もビルドに必要になるので追加しています。

Androidビルドのためにソースを書き換える

Projucerの面倒を見終えたら、今度はソース本体にも変更が必要になります(なりました)。

まず、つい先日書いた話ですが、JUCE on Androidでは同期ダイアログが使えません。

atsushieno.hatenablog.com

Vitalではプリセットバンクのロード/セーブ、WAVファイルの取り込みなどでFileChooserを使い、また各所でAlertWindowを使ってメッセージを表示しているので、けっこうな数の呼び出し部分を書き換えないとビルドできません。手っ取り早いのは「どのダイアログ呼び出しもNot SupportedとしてAlertWindowを(非同期で)出すだけにする」でごまかすやり方ですが、今回は何となくほぼ全部非同期呼び出しに置き換えて対応しています(ちゃんと動いていないところがそれなりにありそう)。

それから、Android NDK環境ではOpenGLサポートまわりでコンパイルに失敗するようなコードがちょこちょこあります。最初に躓くのは実行時に発生するshaderのコンパイルエラー(VITAL_ASSERT(checkShaderCorrect(extensions, shader_id)))でしょう。Android logcatには失敗の具体的な理由が出てきます:

2021-12-24 16:31:48.527 17173-17271/org.androidaudioplugin.vitaloid I/JUCE: ERROR: 0:7: '' : No precision specified for (float)
    ERROR: 0:8: '' : No precision specified for (float)

これに対処するには、translateFragmentShader()translateVertexShader()コンパイルされるシェーダーのスクリプトprecision mediump float;\n を追加してやる必要があります。関連情報はこの辺です:

stackoverflow.com

Vitalのstandalone/vital.jucerをもとにビルドしている場合は、ざっくりこれくらいの対応でビルドして実行できるようになっていると思います(細かく覚えていないので他にもいくつか修正があったかもしれません)。

standaloneとしてビルドした場合

JUCEを更新してpluginとしてビルドする

Androidオーディオプラグインとしてビルドする場合は、プロジェクトの構成が大きく変わってきます。利用する.jucerファイルをpluginのものに変更すると、プラグインアプリケーションのstandaloneビルドの内容も変わってきますし、実際にビルドしたものを起動するとこうなります:

pluginとしてビルドした場合 UIが壊れました。

実はこの問題は最新のJUCE 6.1.4…に至るいずれかのバージョン…で修正されているので、JUCEのソースを更新すると解決します。問題は、vitalのソースに含まれているJUCEには独自の変更が含まれているということです。

そもそも、vitalのソースツリーに含まれるJUCEがどの時点のソースツリーからの差分なのか、わたしには当初は分かりませんでした。JUCEのソースツリーでgit checkout 6.0.xで各バージョンをチェックアウトしながらdiff -ur /path/to/JUCE/modules /path/to/vital/third_party/JUCE/modules で差分の行数を見ていって、6.0.5あたりだと突き止めました。ただその後インポートされたソースファイルの至るところにversion: 6.0.5とヘッダで書かれているので、知っていれば自明だったという話も…。

ともあれ、いったん6.0.5とvitalの独自ソースとの差分を取得してしまえば、これを最新の6.1.4に当てて、パッチを当てられなかった部分を適宜手書きで対応すれば、JUCE 6.1.4でもビルドできるようになります。JUCE 6.1.4向けの差分は、完全ではないかもしれませんがリポジトリに突っ込んであります

JUCE 6.1.0以降にアップデートする場合の注意点として、JUCE 6.1.0ではOpenGLサポートにAPIの破壊的変更が加えられていて、juce::glがデフォルトでインポートされないのでさまざまなOpenGL関数がclangで見つからなくなります。using namespace juce::gl;で対応するのが手っ取り早いでしょう。

これでpluginビルドでも表示がまともになります(standaloneとはセンタリングされているかいないかの違いくらい)。

JUCE 6.1.4に更新後のpluginビルド

現状の課題

Android移植は動くようになりましたが、実用性は今のところあんまりないです。というのは…

  • タッチUIではほとんど何も出来ない: 一見して分かる通り、デスクトップのUIでもかなりでかいものを無理やりモバイルの画面のサイズに合わせているので(自分で対応したわけではないですが)、細かすぎて何も触れないでしょう。移植の動作確認はエミュレーター上でマウスで行っています。
  • 重い: Vitalはもともとかなり多機能で重量級のシンセなので、そもそもデスクトップでも重いと言われています。それをさらにCPUリソースの厳しいAndroidで動かしているので、推して知るべし…です。Advancedメニューでoversamplingを2から1に変更すると少しマシになりますが(Androidの画面だと小さくてどこにあるか分からないやつ)、オシレーターを1つから2つにしたらやっぱり重くなり、エフェクトを設定するとやっぱり重くなって音がブツ切れになるので、まあおもちゃとして使えるか…?というくらいです。
  • テキストやエフェクトのレンダリングがおかしい: OpenGL移植まわりの取りこぼしということでしょうが、テキストが大きくて少しはみ出ているところがちょいちょいあります。これはたぶんscaleの実装でHiDPIに対応してはいても、実画面のほうが小さいLoDPI?な状況には対応できていないということでしょう。
  • ドロップダウンリストのタッチイベントの検出位置もおかしい: たぶんこれもDPI/scaleの小さいやつを想定していない問題で、リスト項目が表示されている位置と実際に発生するイベントに対応する項目の位置が異なる感じです。
  • ファイルダイアログ表示後に再レンダリングがおかしくなる: これは移植の問題なのかもともとの問題なのか正直わかりません。まあ再起動すると直る問題なのでそんなに致命的ではないです(!?)

…とまあ、いろいろ問題があるので、大々的に「作った」とは言い難い感じです。出来る日なんてくるの?という感じですが、ちょいちょいやり方はありそうだなという気もするしオーディオプラグインとしては利用可能性があるので(たとえばプリセットを選ぶだけのプラグインにするとか)、気が向いた時にいじっていこうと思っています。

JUCE6.1以降でも使えるダイアログ

JUCE Advent Calendar 2021 16日目のエントリーです。3回目なので(!)軽めにいきます。

今年リリースされたJUCE 6.1には、いくつかの破壊的変更が加えられています。JUCEでは公式に破壊的変更とみなしているものを全てトップレベルのBREAKING_CHANGES.txtに記録してあって、どんな破壊的変更が加えられたのかを手作業の更新として確認できますが、今回はその中からモーダルダイアログの扱いについてちょっと書きます。

The default value of JUCE_MODAL_LOOPS_PERMITTED has been changed from 1 to 0.

JUCE 6.0.9までデフォルトで許容されていたモーダルダイアログが、JUCE 6.1から無効になります。無効になったものは、利用できるAPIから消えてなくなります。具体的にはこの辺のAPIが使えなくなります:

  • PopupMenu::show()
  • AlertWindow::showYesNoCancelBox() のうちCallback引数がないもの
  • FileChooser::browseForFileToOpen(), FileChooser::browseforFileToSave()
  • AlertDialogAlertWindowComponent::runModalLoop()

Why?

根本的な疑問として、なぜ2021年の今このモーダルダイアログをサポート対象外にする必要があるんでしょうか? 憶測ではありますが、これはJUCEチームによる今後の開発への布石なんじゃないかと考えられます。

そもそも、モーダルループが使えるのはデスクトップ環境など一部のJUCEサポート対象環境のみでした。たとえばAndroid上では今回の変更で使えなくなったような関数はもともと使えませんでした。JUCEアプリケーションをAndroidに移植しているとき、さり気なく面倒になるやつです。これが理由でたとえばaap-juce-dexedではdexedを独自にforkしたandroidブランチを作って利用しています。

Androidと同様にモーダルダイアログをサポートできないであろうプラットフォームとしてはWebブラウザがあります。はい、われわれは一度やっているやつですね。juce_emscriptenでもモーダルダイアログは動きませんでした。

atsushieno.hatenablog.com

JUCE公式ではやっていないはずだし気にしていないのでは…と思うじゃないですか。そういえばこれも今年のcommitなんですよね。(いやマージされたのが今年というだけでコミット自体は12ヶ月前とか出ているな…??)

github.com

emscriptenサポートに向けて内部実装をいろいろ整理していると考えると、今回の動きにも納得感が出てくるのではないでしょうか。

あと、モーダルダイアログは、現代的なUIアーキテクチャとは割と相性の悪い存在です。MVVM and dialogでぐぐるとさまざまな独自ソリューションが実装されてきたっぽいさまが垣間見えます(同じMVVMでもWindows方面とAndroid方面でだいぶ違う話になったりもするので気をつけて読む必要があります)。

デスクトップ向けやっつけ回避策

cflagsに-DJUCE_MODAL_LOOPS_PERMITTED=1を追加すれば従来どおりのモーダルダイアログを使い続けられます。

ただしこの回避策を使うということは、上記のような方針…と考えられるもの…とは相容れないということです。今後のことを考えると、非同期ダイアログの処理フローに転換しておいたほうがよいでしょう。

ちゃんとした対応策

ふわっとした説明になってしまいますが、それぞれのコンポーネントに存在する非同期メソッドを使って書き換えるとよいでしょう。

sync async
PopupMenu::show() showMenuAsync()
AlertWindow::showYesNoCancelBox() callback引数あり版を使う
FileChooser::browseForFileToOpen(), FileChooser::browseforFileToSave() FileChooser::launchAsync()

多くの場合は、既存のダイアログ表示以降の関数の内容をそのままstd::function変数の内容にしてしまって、それをcallbackに継続として渡すなり呼び出すなりしてしまえばよさそうです。

AlertWindow::showYesNoCancelBox()のcallback引数みたいに独自クラスを作らないといけないものはちょっと面倒ですね。個人的にはその場でclassを定義してしまって、コンストラクタではcallbackを渡し、オーバーライドが必要な関数(たとえばModalComponentManager::Callback::modalStateFinished())の中でcallbackを呼び出す、という感じにしています。

AlertDialogComponent::runModalLoop() だけはそうシンプルには書き換えられなそうですが、enterModalState()ModalComponentManager::Callbackを使って書き換えられるのではないかと思います(ちょっと確認するのに時間がかかりそう)。

追記:

書き換えはご安全に…!

まとめ

今後のJUCEの利用場面を考えて、適宜非同期ダイアログを使うやり方に書き換えていきましょう。