augene: MML compiler for sequencer engine

MMLコンパイラからオーディオプラグイン中心の楽曲を生成できるようなツールを作れないものか…と思っていたのですが、ある程度技術的に実現の目処が立ったので、とりあえず限りなく構想段階に近いですが出してみます。コードは現状ntractiveプロジェクトの下でサンプルとして作られています。

github.com

augeneはMMLコンパイラーmugeneを組み込んでオーディオプラグイン中心のシーケンサーエンジン用の楽曲を生成する複合型コンパイラーです。v1リリースでは、オープンソースの楽曲再生エンジンであるところのtracktion_engineを利用した楽曲プレイヤーと、Tracktion用楽曲データ生成ライブラリNTractiveで実装したSMF取り込み機能、それとJUCEのサンプルにあるAudioPluginHostを組み合わせて機能を実現します。

Rationale

mugeneはSMFを生成するMMLコンパイラーです。これはつまり、mugeneの可能性はSMFの範囲に限られるということを意味します。

筆者の作曲作業フローでは、まずMIDI音源を使用してMMLからコンパイルしたSMFを再生していましたが、GM準拠の命令のみではリズムトラックを2つ指定することはできず、ドラムパートのまともな打ち込みは実現が困難でした。コントロールチェンジもエンベロープやカットオフなどが必ずしも使える状態ではなく、また音源が変わると打ち込んだ内容が再利用できないものでした。

チャネル数が16までというのも、本格的に打ち込みが進んでくると問題になってきます。トラック数とチャネル数は異なり、同じ楽器はたいがいチャネルを共有することになり、特にリズムトラックは同じチャネルを共有することになるので、16チャネルあればそれなりの楽曲が作れるのですが、それでも創作がそこで制限されるのはばかばかしい話です。

打ち込んだMMLから生成されたSMFは、その後DAWにインポートして編曲する作業フローとなっていました。この辺りがウォーターフォールモデルなのはまだ許容範囲なのですが、Roland SC-8820など20世紀のハードウェア音源を利用していたときは、音源に特化した命令で調整していた部分は全て打ち込み直しになりました。部分的にMMLからコンパイルしてインポートしたり、極端な場合はXML形式のtracktion楽曲データを直接加工していました。この辺はMMLから一気通貫で変換できれば楽だったというのは間違いありません。

最初からオーディオプラグインを前提とした楽曲が生成できれば良いのに…。Augeneはそのような動機で作られました。

Augene楽曲データフォーマット

Augeneの楽曲データは、それ自体はどちらかといえばプロジェクトファイルとして捉えられるべき、ファイル参照のリストとなります。

<AugeneProject xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <Tracks>
    <AugeneTrack>
      <Id>1</Id>
      <AudioGraph>Unnamed.filtergraph</AudioGraph>
    </AugeneTrack>
  </Tracks>
  <MmlFiles>
    <MmlFile>foobar.mugene</MmlFile>
  </MmlFiles>
  <MmlStrings>
    <MmlString>![CDATA[ 1 @0 V110 v100 o5 l8 cegcegeg  > c1 ]]></MmlString>
  </MmlStrings>
</AugeneProject>

雑なフォーマットですね(!) NTractiveは.NETのプロジェクトで、Augeneはまだ雑にやっつけでXmlSerializerを使ってプロジェクトをシリアライズしているのでこのざまです。これでもDataContractSerializerを使ってやっつけていた時よりはだいぶマシになりました…。JSONではなくXMLをあえて選んだのは、MML中に " やら < やら > やらが出てくるたびにエスケープする羽目になるのは望ましくないからです。XMLならCDATA Sectionが使えるので。実のところ、]]>が出現する可能性はゼロではないのですが、まあ頻繁ではないでしょうし、スペース1つ入れるだけで回避できる問題なので、我慢してもらえればと思っています。

ともあれ、内容は単純で、MMLをファイルあるいは文字列で渡したものと、トラックのリストだけになります。各トラックにはAudioPluginHostで定義して保存したファイルを指定するAudioGraphの情報が必要になります。

Augeneはこのプロジェクトファイルを引数として実行されると、まずMMLコンパイルしてNTractiveのMIDIコンバーターに渡します。

オーディオプラグイン設定はMMLではどうしようもないので、AudioPluginHostで事前にルーティングを設定してもらい、トラックごとにインポートできるように指定するやり方になっています。AudioPluginHostが生成する.filtergraph形式からTracktionの形式へはAugeneが変換します。SMFにはトラックIDが存在しないので、トラックの物理的な位置に対応するIDをユーザーに指定してもらいます(!)。

なおAudioPluginHostでは複雑なルーティングを構築できますが、Augeneは単純な直列接続しかサポートしません。tracktion_engineも複雑なルーティングはサポートしません。

filtergraphもファイル参照ではなく文字列で埋め込めるようにできなくはないのですが、stateのバイナリデータを文字列で書ける強者はそうそういないのではないかと思います…まあstateに頼らないオーディオプラグインもあるかもしれませんし、プログラムで生成できる可能性が無いわけではないので、サポートする可能性もゼロではないです。

実行方法: 楽曲を再生するまで

  • AugeneプロジェクトXMLファイルを作成します。
  • トラックごとに、AudioPluginHostを実行して、フィルターグラフを作成します。各AugeneTrack要素のAudioGraph子要素にそのファイル名を指定します。
  • MMLを作成します。直接プロジェクトに書いても、別のファイルに書いても良いです。MmlStringあるいはMmlFileとして記述します。
  • Augene.exeにプロジェクトXMLを引数として渡すと、それぞれをロードして、tracktioneditファイルとして生成されるべき内容を標準出力に吐き出すので、リダイレクトで保存します。
  • tracktion_engineのPlaybackDemoに、このファイルを渡して実行します。
    • PlaybackDemoの初期状態では、プラグインが何一つロードされていないので、まずプラグインリストを更新する必要があります。
    • また、PlaybackDemoのデフォルトのオーディオ設定では、マイク入力が有効になっていたり、オーディオ出力がspatial audio対応のように多数チャンネルが有効になっていたりして、特にLinuxデスクトップではほぼノイズしか聞こえないような状態になっています。設定を変更して、channel 1 + channel 2だけ有効にすれば、まともにオーディオ再生が機能するようになります。

サポート対象プラットフォームの課題

MMLコンパイラが生成するものは、可能な限り最終的なアウトプットをそのまま生成できることが望ましいです。ただ、これは次に説明するプラットフォーム依存性の問題があるため、妥協的な解決策を導入します。

オーディオプラグインを利用できる環境はクロスプラットフォーム化しつつありますが、オーディオプラグインそのものはまだほとんどがクロスプラットフォーム化できておらず、特に筆者が主に使用しているLinuxデスクトップのサポートが壊滅的です。

また、オーディオプラグイン機構のクロスプラットフォーム化はまだ「実現中」のものが少なからず存在します。JUCEはLinuxのVST3をサポートできていません。Augeneの動作確認は、Waveform上でVST2であるところのCollectiveを使用した楽曲を、VST2SDKを有効にしたtracktion_engine(プロジェクトのjuce_audio_plugin_processorsの設定に変更を加える必要があります)のPlaybackDemoに渡すことで成功しています。

オーサリング環境

実のところオーサリング環境のサポートが無いところで楽曲を打ち込むのはまだだいぶ苦痛だと思うので、何かしらツールを用意したほうが良いかと思っています。さすがにPlaybackDemoではまともなプレイヤーになっているとは言い難いですし。ただNTractiveやmugeneは.NETツール、PlaybackDemoはネイティブ(C++)のツールと、分かれているものをある程度統合する必要が出てくるかもしれません。とりあえずは子プロセスを起動するやり方でしのぐつもりです。

またPlaybackDemoだけがプレイヤーになっている現状はかなり残念なので、Waveform10にもロードできるように、プロジェクトファイル(*.tracktion)も生成できるようにしたいのですが、このファイルはバイナリなので、tracktion_engineがやっていることを自前で(?)再現する必要があります。理想を言えばYeoman generatorあたりを実装して yo tracktion くらいで生成できるようにしたいところです(!?)。

あるいはもっと単純にAugeneにWaveformプロジェクトの一部を書き換えるだけの機能を実装してもよいのですが、その辺はもう少しいろいろ試してみたからにしようと思います。

juicysfpluginをUbuntuデスクトップ環境で使うためのまとめ

小ネタ。

サウンドフォントにはなかなかいい音をもっているものが多く、また統合音源として各種サンプルをバンドルしているものはMIDI楽器として楽に使いやすいので、それなりに活用したい。

juicysfpluginはfluidsynthを利用してサウンドフォントをVSTiにしてしまおうというJUCEベースのオーディオプラグインだ。Linuxでも使えるようには作られているようだ(外部コントリビューターがLinuxサポートを追加している)。

github.com

ただし、juicysfpluginはfluidsynth 2.0系列を前提としている。Ubuntu用にパッケージされているfluidsynthは(というか大半のLinuxデスクトップ環境のfluidsynthが)やや古い1.1系列のもので、fluidsynth 2.0とはAPIがだいぶ異なるようだ。そのため、まずfluidsynth 2.0をビルドするところから始めないといけない。

github.com

juicysfpluginのチェックアウトのトップディレクトリにgithubからチェックアウトしてcmake -DCMAKE_INSTALL_PREFIX=../../fluidsynth-distのようにしてビルドすると、make/make installの後にトップレベルのfluidsynth-distができるはずだ。

JUCEがVST3をサポートしていないLinux環境用にJUCEのプラグインをビルドするには、VST2_SDKを含むVST3SDKが必要になる。githubのリリースからVST2_SDKを含む古いSDKを拾えるようだ(自分でビルドまでしたことはないけど)。

それが出来たら、次はjuicysfplugin.jucerからそれを参照するようにする必要がある。理想を言えばjuicysfpluginがlibfluidsynth.aをstaticリンクすることで余計な外部依存ライブラリをなくしたいところだが、とりあえずは共有ライブラリをリンクすることにする。Projucerで開いて、Linuxビルドの設定でCPPFLAGSに-I../../fluidsynth-dist/include、LDFLAGSに-L ../../fluidsynth-dist/lib64を指定する(fluidsynth 2.0.7時点ではなぜかlibではなくlib64に生成されるようだ)。

いったんfluidsynth 2.0がビルドできたらjuicysfplugin.jucerを保存してBuild/Linux/Makefileを生成し、make -C Build/Linuxで juicysfplugin.soをビルドする。

juicysfplugin.soがビルドできたら、これを~/.vstにコピーすれば良い…というわけではない。このjuicysfplugin.soにはlibfluidsynth.so.2.0.xへの強参照が含まれており、このファイルがLD_LIBRARY_PATHなどでロード可能なパス上に存在していないと、プラグインホストがjuicysfplugin.soのロードに失敗する。~/.vstにlibfluidsynth.so.2.0.xを置いてもロードはしてもらえない。

自分の手元の環境ではfluidsynth 2.0.xを含むlib64にLD_LIBRARY_PATHを追加したターミナル環境からWaveform10などを起動することで呼び出せている。

f:id:atsushieno:20191005213812p:plain
juicysfplugin on Waveform10 on Linux

Xamarin.AndroidXMigrationについて

今回はコレに関連して少し詳しく書く。

devblogs.microsoft.com

Xamarin.Androidは時代遅れ

Xamarin.AndroidとスタンダードなKotlin中心のAndroid開発の乖離は年々ひどくなっている。たとえば

  • data-binding, room: Android Gradle Pluginの中で行われるコード生成を前提としているので難しい。
  • constraint layout designer: constraint layoutはそれ自体はNuGetパッケージで配布されていて、実行時に必要なライブラリとしては使うことができるが、テキストエディタ上で書くには冗長で区別の付けにくい属性が多用されていて、UIデザイナーが無いと使いにくい機能の一つだ。
  • navigation editor: これも実行時ライブラリとしては単にバインドするだけだが、GUIデザイナーで流れを把握できるのと出来ないのとでは大きな違いがある。
  • instant run, apply changes: Xamarin.Androidにはinstant runライクなfast deploymentが実装されているが、デフォルトで有効になっている機能としては、あくまでDLLのみの変更にしか対応していない。Java生成コードの変更を伴うimproved fast deploymentサポートは無効のままだし、その間にAndroid Studioはinstant runを捨ててApply Changesに移行してしまった。

最後に、Kotlin + Android Studioでの開発においては、AndroidサポートライブラリからJetpackへの移行が進んでいる。次の節ではこれについてもう少し詳しく説明しよう。

androidx (a.k.a Jetpack)

Androidサポートライブラリは、Android APIフレームワークレベルで機能追加されたもののうち、特に新しいハードウェア要件などに依存しない部分を、古いバージョンのAndroidでも利用できるようにバックポートしたものだ。android.support.v4.*というパッケージはminSdkVersion=4であるような追加APIが、android.support.v13.*というパッケージならminSdkVersion=13であるような追加APIが、それぞれ含まれていた(他にもv7, v17などいくつか存在した)。

これらのバージョン番号に基づく識別子は、最初は正しかったが、長い年月を経て、Google PlayAndroid API Level 4のサポートが打ち切られたりする中、だんだん意味をなさなくなってきた。またAndroid Architecture Componentはandroid.arch.*というパッケージ名だったが、将来的にはこれらとサポートライブラリの関係なども錯綜する可能性が高く、新機能がどちらに属するのかといった疑問など、トラブルの予感しかなかったといえる。

Googleは2018年にJetpackという新しいブランディングandroidx.*というパッケージ名のもとで、古いサポートライブラリとアーキテクチャコンポーネントを統合した。そしてminSdkVersionは各パッケージごとに決まる事項となった。Androidフレームワークに追加する必要がない新機能はJetpackでサポートすれば良く、しかもフレームワークのリリースサイクルからは独立して基盤的な新機能をリリースできるようになった。

また、Jetpackの大きな側面は、Javaが前提だった頃に作られたライブラリを、Kotlin前提の構成に置き換える、ということにもある。Xamarin.Androidの文脈では「ネイティブ」としてKotlinよりも(C++よりも)Javaのほうが出てくるが、これはmonoランタイムとの相互運用の文脈で出てくるのがほとんどなので、今後も変わらないだろう。これ自体は別にXamarinがKotlin前提のエコシステムに追いついていないということは全く意味しない。

androidxへの移行の課題

さてAndroid開発者はこのJetpackへ移行することが求められているのだけど、これは簡単にbuild.gradleの中にあるパッケージを差し替えるだけでできるようなことではない。パッケージ名が変わっているので、ソースコードも書き換えなければならない。そしてJetpackパッケージは膨大である。 https://developer.android.com/jetpack/androidx/migrate にリストがあるので見てみてほしい。

これはとても全部追っかけていられない

仮に膨大な肉体労働の後に自分が書いたアプリケーションのコードを移行できたとしても、それだけでは十分ではない。自分がアプリケーションで参照しているライブラリがJetpackに移行していない場合は、結局古いサポートライブラリが含まれてしまうことになる。

運の悪いことに、Android Studioのデフォルトテンプレートで作成されたアプリケーションやライブラリは、古いバージョンのAndroidでも動作できるようにandroid.support.v4*の各種パッケージに含まれるクラスが使われていた。

依存ライブラリがバイナリとして参照しているものを、そのユーザー開発者が書き換えるのは無理がある。依存ライブラリがandroidxに移行してくれるのを待つべきだろうか? そもそもそのライブラリの開発者は今でもそのライブラリをメンテしているだろうか? そしてタイムリーに対応してくれるだろうか? 依存ライブラリの開発者が1人でも「まだ…その時ではない」と考えていたら、移行計画が実現することはないだろう。

Jetifier

この問題を解決するためにGoogleが出してきたのがJetifierという自動移行ツールである。Jetifierは単独実行可能なツールとしても存在するが、Android Gradle Pluginでサポートされているので、通常は次のような記述をbuild.gradleに含めて使用することになるだろう(今回はGradleの話ではないので説明は最小限にとどめる)。

  • android.useAndroidX=true
  • android.enableJetifier=true

Jetifierは内部的に次のようなことをやっている。

  • コード中に含まれるandroid.support.*への参照をandroidx.*に変換する
    • ただしパッケージ名だけの変換になるとは限らない
  • binary to binary compiler (jar/aar/zip, ソースではやらない)
  • Java, XML, POM, proguard configs (がaarに含まれている)

ちなみに単独実行ツールでは逆向きにも変換できる… jetifier-standalone -r -i input.aar -o output.aar

ちなみに、移行対象のアプリ中にandroid.support.xyzとandroidx.xyzが混在していても問題なく動作することになっている。移行作業は段階的に行われるはずなので、これが求められている挙動であるといえる。

また、Googleの公式ライブラリ以外でandroid.support.xyzというパッケージが含まれている場合もandroidxに置き換える対象となるようだ。この場合はパッケージ名以外で何を置き換えるのが正しいのかツール側にはわからないので、パッケージ名だけが置き換わる。

いずれにしろ、Jetifierについて改めて強調しておくべきことがひとつある。Jetifierは移行を簡単にするためのツールではなく、移行を実現するためにほぼ必須となるツールだということだ。アプリケーション開発者にはバイナリ参照と手作業で書き換える適切な手段が無いのである。

Xamarin.AndroidJetpack移行戦略

Jetpackへの移行は、もちろんXamarin.Androidについても他人事ではない。Xamarin関係のライブラリの多くはAndroidサポートライブラリに依存している。それはVisual Studio for Mac / for WindowsのテンプレートでもAndroid.Support.v4への参照がデフォルトで含まれていたり、Xamarin.FormsであればそもそもAndroidプラットフォームバインディングXamarin.Forms.Platform.Android)がそうなっていたからだ。

Xamarin.Androidの場合、問題はJetpackよりも悪化している。AndroidバインディングとはDLLであり、NuGetパッケージに含まれるバイナリである。そしてソースコードが無いことも少なくはなく、これを自前で何とかするのは無理がある。

Jetpackへの移行は、本家がアプリケーション開発者の独力では対応できないと判断したタスクなのだから、Xamarin.Androidでも自動対応ツールが必要であると考えるのが妥当だ。そういうわけで、Xamarinからは新しくXamarin.AndroidxMigrationというパッケージが爆誕した。

github.com

JetifierはAndroid Gradle Pluginのタスクとして対応したので、ユーザーはgradle.propertiesに2行追加するだけで済んだ。Xamarinというか.NETの場合は、Gradle Pluginのようなことを実現するためには、MSBuildタスクを含むNuGetパッケージを追加して、ビルドをinjectするかたちで対応する。

ちなみに、Xamarin.Androidのandroidxのバインディングは、2019年7月になってNuGet上に出現するようになった。 https://www.nuget.org/packages?q=xamarin.androidx で検索してみるとよい。ソースコードhttps://github.com/xamarin/AndroidSupportComponents/tree/AndroidX (AndroidXブランチ)にある。ここにはroomなども含まれているが、あくまでライブラリのバインディングに過ぎず、Java/KotlinであればAndroid Gradle Pluginによって利用できていたroomがXamarinで使えるようになったわけではない。

Xamarin.AndroidXMigrationの仕組み

Xamarin.Androidの仕組みについて把握している人なら知っていると思うが、Xamarin.Androidアプリケーションでは、アプリケーション中のDLLで使われているJava派生クラス(Managed Callable Wrapper)からJNIで呼び出されるJavaクラスを含むjarが生成されてパッケージされる。このJarを他の参照ライブラリに含まれるJarと合わせてJetifierの対象として変換を行う。

そしてDLL中の旧ライブラリへの参照をXamarin.AndroidXへの参照に置き換える。Jetifierが行っているようなバイナリ書き換えを、DLLについても行っていることになる。バイナリ書き換えはcecilでやっている。

NuGetパッケージを追加するだけでこの変換機構が自動的に動作する仕組みは、NuGetのMSBuildタスク拡張機能を利用している。これについては日本語ではここで詳しくまとめられているようだ。

www.kekyo.net

ただし、適用されるMSBuildタスク拡張の順番は制御できないので、これはやっつけ仕事に類するものだ(23:30追記: 別のパッケージが、関連するTargetにTargetDependsOnで参照アセンブリのリストやそのアセンブリ群に手を加えた場合のことを考えるとよい)。ユーザーとしてはビルドの「最後」に適用されることを祈るか、そうなるように各プロジェクトファイルをいじることになるかもしれない。(ちなみにGoogleAndroid Gradle Pluginは拡張タスクではなくプロパティで実現しているので、「正しい」雰囲気がある。)

単独ツールとしては androidx-migrator.exe でも実行可能だ。そして、Xamarin.AndroidXMigration.ToolというNuGetパッケージがdotnet toolとして作られている。これは新しいMSBuildの記述スタイルであれば Sdk="Microsoft.NET.Sdk"<IsTool>true</IsTool><PackAsTool>true</PackAsTool> の組み合わせだけで作れる(パッケージがそうやって作られているという意味であって、ユーザーはこう記述する必要はない)。

Xamarin.AndroidXMigrationの注意点

公式ブログにも書かれているが、Xamarin.AndroidXMigrationを適用したアプリケーションには、Xamarin.Android.Support.*Android.Arch.*への参照を(間接的にでも)残しておかなければならない。AndroidXMigrationはアプリケーションをいったん古いコードでビルドすることが前提になっているし、こうしておかないと、AndroidXMigrationが移行元のアセンブリとしてのXamarin.Android.Support.*をロードできなくなってしまうためだ。

その他

androidxへの移行は他のフレームワークでも問題になる。たとえばReact Nativeでも同様のjetifierツールが開発されているようだ。Flutterでも1.7リリースの時にAndroidXへのサポートは作業中である、と説明している(1.9リリースノートにはJetpackやJetifierへの言及が無い)。

JUCEでエラーを黙殺しないテストの構築

JUCEはC++フレームワークだが各所にJUCE固有の特徴が見え隠れしている。そのひとつがjassertというアサーション機構だ。jassertfalsejassert()の引数を取らない常に失敗するやつ)は、デバッグ実行時にはアサーションエラーを引き起こし、その後何事もなかったかのようにアプリケーションを続行する。これがもしassertであれば、アプリケーションはきちんとクラッシュする。

これはアプリケーション実行時にユーザーが直面するクラッシュを引き起こさないという意味では開発者が望む機能と言えるかもしれない。しかしコードの品質を上げるためには、このようなエラーを握りつぶすjassertfalseを引き起こすようなコードはテストでは看過されるべきではない。せっかくjassertfalseを起こしたことで発覚したエラーは、正しくテストの失敗として報告される必要がある。VBでOption Strictを外したり、テストメソッド全体をtry ... catch (Exception e) {}で囲まれたようなコードでは、まともなテストとは言えない。現在のJUCEのエコシステムにはこの考え方が欠落しているのである。

われわれはコードの品質を向上させるために、CIサーバー上でビルドを行いテストを実行する(べきだ)。なぜCIが重要なのかというと、JUCEの場合はさまざまな環境でビルドして実行できるものの、プラットフォーム固有のオプションを指定する余地がさまざまな場所に存在していて、特定のプラットフォームでしかビルドできないコードが書かれる可能性が高く、また同じコードでもプラットフォームによって失敗するという状況が頻繁に生じるためである。これはJUCE固有の問題ではなく、クロスプラットフォーム開発ツール全般に生じる問題だ。

しかしJUCEコミュニティにはそもそもCIサーバー上でビルドとテストを実行する慣習が無い(断言)。問題のあるコードがCI上のテストで露見したらきちんと失敗を報告する、というのはQAの基本なのに、それが出来ていないのである。何はともあれ、JUCEコミュニティにそういう意識が無いのであれば、自分でまとめるしかない。そう思って書いているのがこの文章ということになる。

JUCEのテストはどうやって書くのか

「まともなテストはどうやって書くのか」という話をする前に、まずそもそも「JUCEではどうやってテストを書くのか」というレベルの話をしなければならないだろう。JUCEコミュニティにはそもそもJUCEのunit testingに関する知見がほとんど共有されていない。

JUCEにはjuce_coreモジュールの中にUnitTestRunnerというクラスがあり、これを自分でどこかしらで呼び出して実行することになる。JUnitNUnitのように単体で実行できるtest runnerがあるわけでもなければ、IDE統合でテストフレームワークがサポートされているわけでもない。JUCEアプリケーションの場合、GUIを使うか、仮にGUIが使われていないとしてもアプリケーションループを使うことが多い関係で、一般的には、アプリケーションループを立ち上げてから、その中でUnitTestRunnerを使うことになるのではないかと思う。

UnitTestRunnerは、runAllTests()を呼び出すと、登録されているUnitTestを全て実行する。この「登録」とは何なのかというと、UnitTestクラスの変数を宣言(あるいはnew)すると、そのコンストラクターの中で自動的にUnitTestRunnerが実行対象とするインスタンスのリストに自身を追加するのである。テスト作成者としては、テストクラスを定義してそのインスタンスを宣言するだけでよいことになる。

さてこのUnitTestの仕組みは、JUCEを使っているプロジェクトでどのように使われているのだろうか。

先例1: JUCE

先のJUCEのUnitTestの書き方は、JUCEのソースコードから読み取ることが出来る。実際、英語圏のJUCEまわりのコミュニティでUnitTestRunnerやUnitTestの使用例が見つからない、と発言したら、JUCEのソースを見てみろ、と言われるだろう(経験談)。ちなみにテストはこんな感じで実装と同じコードの中に含まれている。というか、juce::UnitTestの使用例がほとんどこのリポジトリにしか見つからない。

https://github.com/WeAreROLI/JUCE/blob/3a4c0f901204a1aeec05597421da80acff403a4a/modules/juce_core/containers/juce_AbstractFifo.cpp#L171

問題は、JUCEのリポジトリではテストを実行している様子が見られないということだ。CI関係のセットアップとしては、なぜかgitlabの設定ファイルがある。CIだけgitlabでプライベートリポジトリを作って運用しているのかもしれないし、結局使っていないのかもしれない。外部の人間にとっては参考にならない、ということだけがわかる。

先例2: tracktion_engine

わたしがよくコードを追っかけているtracktion_engineのdevelopブランチでは、Azure Pipelinesを使用してLinux, Mac, Windowsの3プラットフォームでビルドを実行していて、わたしもこれをベースに自社プロダクトでAzure Pipelinesをセットアップした。

しかしこのtracktion_engineのCI設定にはテスト実行が伴っていない。実のところテストそのものが「かろうじて存在しているけど、ほとんど存在していないと言っていいレベル」である。

/sources/tracktion_engine$ grep -nR UnitTest tests modules/tracktion_engine/
modules/tracktion_engine/tracktion_engine.h:132:    If enabled, these will be added the UnitTestRunners under the "Tracktion" category.
modules/tracktion_engine/utilities/tracktion_ConstrainedCachedValue.cpp:16:class ConstrainedCachedValueTests   : public juce::UnitTest
modules/tracktion_engine/utilities/tracktion_ConstrainedCachedValue.cpp:20:        : juce::UnitTest ("ConstrainedCachedValue ", "Tracktion") {}
modules/tracktion_engine/model/edit/tracktion_TempoSequence.cpp:1172:class TempoSequenceTests : public UnitTest
modules/tracktion_engine/model/edit/tracktion_TempoSequence.cpp:1175:    TempoSequenceTests() : UnitTest ("TempoSequence", "Tracktion") {}
/sources/tracktion_engine$ git log -n 1
commit 9a844e09134b701efe6056bcd2964acf7fa809f0 (HEAD -> develop, origin/develop)

JUCEのテストコードをCIサーバー上で実行する

前節では、JUCEのUnitTestRunnerがアプリケーションの任意の位置に追加して自分で実行すべきものになっている、という話を書いた。テストを実行するためには、まずビルドしなければならないし、もしProjucerで生成したファイルをリポジトリに含めていなければ、Projucerを実行するところから始めなければならない。

CIサーバー上でProjucerを実行するやり方については以前にここで書いたとおりなのだけど、必要な部分をもういちど適宜抜粋する。

atsushieno.hatenablog.com

特にJUCEアプリケーションを実行する時に必要になるのは(Linuxの場合は)xvfb-runだ。

xvfb-run -a --server-args="-screen 0 1280x800x24 -ac -nolisten tcp -dpi 96 +extension RANDR" 実行したいコマンド

CIターゲットがmacOSWindowsの場合は、単純にアプリケーションを実行するだけでよい。もっともMacの場合はbashから呼び出すものは Foo.app/Contents/MacOS/Foo のようになるだろう。

わたしの場合は、ここにビルドとテストを両方実行するスクリプトを渡している(xvfb-runをProjucerとテスト実行の両方で指定するのが面倒なので)。実行プログラムは--run-unit-testsという引数があったらテストを実行して自動的に終了するように作られている。

jassertfalseを退治する

さて、ここまでやっておけばCIサーバー上で単体テストを実行してリグレッションが無いか確認することができる。と思うわけだが、実際にはこれでは十分ではない。そう、本稿の契機になったjassertfalseがエラーを握りつぶす問題である。実際に次のようなテストを追加して実行してみよう。

class DummyTest : public UnitTest {

  public DummyTest() : UnitTest("DummyTest") {}

  void runTest() {
    String s{"\u3102"};
    expectEquals(s.length(), 1, "length");
  }
}

このコードは間違っているのだが、それに気付くJUCEアプリケーション開発者はほとんどいないかもしれない。正しいコードはこうだ。

    String s{CharPointer_UTF8("\u3102")};

こう書かないとStringのコンストラクターは入力文字列がASCIIの範囲に収まらない文字を認識するやいなやjassertfalseを引き起こす。これは予期しないエンコーディングの文字列を渡しがちな開発者にとっては有り難いjuce::Stringの挙動だ。ではこういうコードが含まれていたらrunTest()の結果はどうなるか?

JUCE Assertion failure in juce_String.cpp:345
All tests completed successfully

"All tests completed successfully" である。メッセージだけではなく、テスト結果を格納するUnitTestRunner::TestResult.failuresにはご丁寧に0が入っている。何をバカな…と思われるかもしれないが、jassertfalseはエラーを報告した後で握りつぶすので、テスト結果としてはこのような挙動になるのである。これではまともなQAにならない。expect()やexpectEquals()でチェックしているコード以外でも、jassertfalseが発生した場合にはきちんと失敗扱いしてCI上も「テスト失敗」として報告される必要がある。

そういうわけで、jassertfalseを「退治する」必要がある。これを実現するアプローチは複数ある:

(1) jassertfalseの出力するメッセージをLoggerで捕捉して、最後に発生していたjassertfalseに対処する

比較的簡単に実現できる方法としては、テスト実行中にjassertfalseによって呼び出されるLogger::writeToLog()が呼び出されるLoggerインスタンスLogger::setCurrentLogger()で独自に設定して、UnitTestRunner.runAllTests()が終わった後にjassertfalseの呼び出しがあったかどうか検出する、というやり方だ。

jassertfalseの呼び出しがあった場合には、アプリケーションをクラッシュさせることでexit codeに0以外を返させて、CIに「失敗」扱いさせるのが手っ取り早い。ちなみに、このときLoggerで記録しておいたメッセージを全部出すと親切そうにも見えるが、実際にLoggerに渡されるのはJUCE Assertion failure in juce_String.cpp:345のようなスタックフレームのない情報なので、ほとんど参考にならない。開発者に"JUCE Assertion failure"でログをgrepしてもらったほうが早いだろう。

実際には、jassertfalseがLogger::writeToLog()を呼び出すようにするためには、JUCE_LOG_ASSERTIONSという定数が0以外に定義されていないといけないので、これを*.jucerファイルで記述しておいて(Projucerで全体設定の部分にある定義済みシンボルに追加する)、プロジェクトを再生成する必要がある。

理想を言えば、jassertfalseは各テストのスコープのうちにfaluresがきちんと記録されるようにしておきたいものだが、UnitTest.runTest()のスコープからアクセスできる範囲にはTestResultが存在しない(派生クラスでUnitTestRunnerを渡すようにしても、UnitTestRunner.getTestResult()の戻り値は参照ではないので値を変更できない)ので、そこは妥協するしかなさそうだ。

(2) jassertfalse自体を書き換えてエラーを握りつぶさないようにする

もうひとつ、こちらはJUCEコミュニティで教えてもらった方法として、単なるマクロとして定義されているjassertfalseを書き換えて throw std::runtime_error{"message"} みたいな内容にする、というやり方だ。AppConfig.hで定義すれば、JUCE本体のソースコードを書き換える必要もないはずである。

最初のjassertfalseでいきなり例外を出してテストが中断されるのは、テストフレームワークとしてはNGだ。複数人でプロジェクトを開発していて、誰かのテストが失敗する状態にmasterが陥っていた場合、それが直るまで他のテストが正常に通過しているかどうかが分からないというのでは、まともな開発ができない。だからここはthrowではなく、あくまで「後で失敗していたことがわかるように記録しておく」やり方が望ましい。

成果

この仕組みに基づいてテストランナーを実装した結果(ほんの5-6行のコードだ)、テスト中にjassertfalseが発生したら、CI上もエラー終了するようになった。

f:id:atsushieno:20190913002743p:plain
assertion failures cause crashes

安定的で合理的なデバッグ作業を実現するApply Changes @ C96

ひさしぶりに技術同人誌に寄稿しました。TechBoosterのC96(夏コミ)新刊です。4日目南ナ49abのようです。boothで予約も出来るようです。

techbooster.booth.pm

今回はAndroid Studio 3.5でInstant Runの代替機能のように紹介されて登場したApply Changesと呼ばれる機能について、掘り下げて解説しています。とは言ったものの、実のところ20ページ弱あって長過ぎると思い、ソースコードまで追っかけた部分をバッサリ切り落としたので、このエントリでは補遺としてそれをまとめておこうと思います。

本エントリはあくまで補遺なので、上記同人誌の本文を参照できる人でないと読み進めるのは辛いと思います。Apply Changesについて後述するProject Marbleの投稿を読んでいて理解している人なら大丈夫かもしれません(一応)。

Instant Runについておさらいしたい場合は、2016年にわたしがまだQiitaを使っていた頃にまとめてあるので、そちらを見てください。

Instant Runはデバッグビルドに特化した技術でしたが、Apply Changesは実のところデバッグも意識しつつデバッグビルドに特化しないデプロイメント技術である、と言ったほうが適切かもしれません。今回の草稿の情報源の多くはAndroid Studio開発チーム?の外郭チームっぽいProject Marbleのメンバーによるブログ投稿なのですが、実はこの投稿だけではInstant Runが実現したビルドの省力化のうち、Apply Changes以外の部分については言及していないんですね。なので「じゃあInstant Runで実現していたアレやコレはどーなったの?」という話も実はあるのですが(たとえばターゲットと無関係なABIやリソースをスキップするビルドの最適化部分とか)、その辺はまだ文章としての情報が無いので言及していません。

と、前置きしたところで、本題に入ります。

(1) Apply ChangesはAndroid Studioの機能の一部として存在しています。Android Gradle Pluginではありません。Instant Runがビルドの最適化を中心とした機能であったのに対し、Apply Changesはあくまでビルドされたアプリケーションの更新適用("apply" "changs")に本質があって、これは主としてGradleで操作するものではありません。いずれにしろ、AOSPで参照すべきブランチはstudio-master-devです。

(2) Apply Changesの主要な実装部分は、AOSPでいえばtools/base/deployに含まれています。ここにApply Changesにおけるdeployerの実装、差分のジェネレーター、Androidターゲット側で動作するdeployagentとの通信スタックやそこで使われるprotocol bufferの定義ファイルなどが含まれています。

(3) 一方で、Apply Changesの実装をカバーしているように見えるけど、多分Instant Runの改良部分なのではないかと思われるコードも、tools/adt/ideaの中のandroid/src/com/android/tools/idea/以下、特にstats, deploy, runにいくつかあります。runディレクトリにはアプリケーションのデプロイメントについて特に詳しく解説したREADME.mdが含まれているのですが、ここにはApply Changesへの言及はひとつもなく、どうやらInstant Run時代のデプロイメントについてのみまとめた内容であるようです。

(4) 本稿ではAndroid 8.0でARTのRuntime Instrumentationのために新しく実装されたJVMTIによってhot code replaceapply code changesが実現しているということを説明していますが、具体的にはUpdateMethodsCode()という関数がこれを実現しています(AOSPページでpermalinkを取得する方法がわからなかったのでmasterへのリンクです…!)。JVMTIはOracleのhot code replaceで使われている技術で、Java Platform Debugger Architectureと呼ばれる一連のデバッガー関連仕様の一つです。標準的なコード置換技術なので、JVMTIをサポートしているどんなIDEでも原理的にはデバッガーからコードの差し替えが可能になっているかもしれません。

(5) なお、これと関連して、tools/adt/idea/android/src/com/android/tools/idea/fd/actionsでは、JVMTIが「有効になっていれば」Instant Runは実行されないようになっています。先に言及したQiita記事で軽く言及していますが、パスの途中にあるfdというのはInstant Runの機能の一部となるfast deploymentの略であると考えられます(Instant Runランタイムのjarにも含まれている名前です)。

(6) ちなみにApply Changesと関係があるのかはわからないのですが、adbにもfastdeployという機能?が新しく追加されていて、この中にdeployagent、deploypatchgeneratorといったツールのソースがいくつかあります。これらは「Androidターゲット上で動作する実行ファイル」であり、Apply Changesで使われているものと一致するかもしれません。

…というわけで、Apply Changesについてわたし以上に興味のある人は、この辺りから深入りできるのではないかと思いますので、ぜひソースコードリーディングにチャレンジしてみてください。

CircleCI(など)でJUCEアプリケーションをビルドする

JUCE GUIアプリケーションをCircleCIでビルドしてテスト実行するにはいろいろ面倒事を解決しないといけないのでまとめておく。CircleCIでやっているのはたまたまなので、他にも任意のDocker Imageを使用してビルドできるCI環境があれば応用がきくはず。実行環境は今回はUbuntuを想定している(パッケージ名等を調整すれば任意のLinuxイメージで動くはず)。

アプリケーションを起動してテストを実行しないとしても、Projucerによるビルドファイル生成が含まれている場合は、結局依存パッケージが必要になるので、およそ面倒事は避けられないのではないかと思う。リポジトリに生成済みのビルドファイルまで含めておくのであれば、この限りではない。

docker image

今回は ubuntudesktop/gnome-3-28-1804 を使っている。JUCE GUIアプリケーションはX11で動いており、そのために必要な環境があらかじめ設定されているイメージが望ましい。ただ、依存パッケージの中にlibwebkitgtk-4.0-devがあり、これは実のところかなり多数の追加依存パッケージを巻き込むはずなので、標準的なubuntuイメージでも十分かもしれない。

依存パッケージが増えすぎるとCI実行時間が無駄に長くなるのでなるべく避けたいところではあるけど、CircleCI上で40秒くらいだったので、まあ…仕方ないかな…という気持ちでそれ以上は追求していない。気になるようであれば独自のdocker imageを公開しておいたほうがよいかもしれない。

package deps

依存パッケージは現状これくらい含まれている。

echo y | apt-get install xvfb wget unzip libc6 libcurl3-gnutls-dev  \
    libfreetype6-dev libgcc1 libjpeg-dev libpng-dev libstdc++6 \
    libwebkit2gtk-4.0-dev libx11-6 libxext6 zlib1g  make g++ \
    mesa-common-dev libasound2-dev

Projucerの実行時にlibcurl3-gnutlsとlibfreetype6が必要になるが、その後juce_gui_basicsのモジュールをビルドする際にlibwebkitgtk-4.0-devとその依存関係が必要になる。juce-audio-basicsをLinuxでビルドするにはlibasound2-devも別途必要になる。

JUCEのダウンロード

Ubuntu bionic以降にはjuce-toolsのパッケージが含まれているので、それをそのまま使うという手もあるが、バージョンは5.4.1など多少古いものになる(現時点で5.4.3)。JUCEのAPIはバージョンによって割と変更されていくることがある(筆者は最近juce-coreのXmlAPIが5.4.3+でだいぶ変わってきていることに気づかずにハマった)。公式サイトのリンクから取得するのが安牌ではないかと思う。

xvfbを使用してProjucer, make, アプリケーション(テスト)を実行する

CircleCIのような環境でGUI依存のコードを実行しようとすると、おなじみのcannot open DISPLAY:0のエラーになって終了することになる。chromeなどであればheadlessビルドを使用してテストまで実行できることもあるが、GUIを使用するコードをそのまま実行できるようになってほしい、というのは割と一般的な需要だろう。

Chromiumをheadlessではない状態でビルドして実行するやり方をまとめているブログがあったので、そのやり方を真似してみた。

xvfb-run -a --server-args="-screen 0 1280x800x24 -ac -nolisten tcp -dpi 96 +extension RANDR" 実行したいコマンド

Projucer --set-global-search-path

Projucerには--resaveというオプションがあるので、これを実行すれば*.jucerファイルからLinuxMakefileを生成できるのだけど、ここには一つ罠があって、Projucerはローカル環境で設定されているグローバルパスを必要とする。これがないとJUCEモジュールの参照を適切に設定できずにファイル生成に失敗する。

このグローバル設定は--set-global-search-pathというオプションで指定できる。このオプションは実行するOSと対象パスを引数として要求する。JUCEのパスと、ビルドするコードやモジュール設定によってはVST3 SDKなどのパスも必要になる。

注意すべきは、このオプションと--resaveは排他的だということだ。つまり、先に--set-global-search-path等を別途実行してから、--resaveを実行すればよい。

サンプルconfig.yml

version: 2
jobs:
  build:
    docker:
      - image: ubuntudesktop/gnome-3-28-1804

    steps:
      - checkout

      - run:
          name: update repos
          command: apt-get update

      - run:
          name: install packages
          command: echo y | apt-get install xvfb wget unzip libc6 libcurl3-gnutls-dev libfreetype6-dev libgcc1 libjpeg-dev libpng-dev libstdc++6 libwebkit2gtk-4.0-dev libx11-6 libxext6 zlib1g make g++ mesa-common-dev libasound2-dev

      - run:
          name: get JUCE 5.4.3
          command: wget https://d30pueezughrda.cloudfront.net/juce/juce-5.4.3-linux.zip

      - run:
          name: unzip JUCE
          command: unzip juce-5.4.3-linux.zip

      - run:
          name: build and run tests
          command: JUCE_DIR=`pwd`/JUCE xvfb-run -a --server-args="-screen 0 1280x800x24 -ac -nolisten tcp -dpi 96 +extension RANDR" run.sh

自分のモジュールでは、run.shの中に、Projucerの実行などをまとめている。

$JUCE_DIR/Projucer --set-global-search-path linux defaultJuceModulePath $JUCE_DIR/modules
$JUCE_DIR/Projucer  --resave 自分のコードの.jucer

cd Builds/LinuxMakefile && make && cd ../..

Builds/LinuxMakefile/build/アプリケーション (実行して自動的にテストを実行して終了するオプション)

ビルドだけで用事が済むのであれば、最後のステップは不要だ。

6月のmusic tools hacks, あるいはLV2 for Android

もっと早くに実現していたはずだったのですが、体調不良などに引きずられて今頃になって台湾に渡ってフラフラ過ごしています。人に会う用事が毎日のようにあって、日本の引きこもり生活とは雲泥の差が…

無駄話はさておき、5月にAndroid Native MIDI APIについて書いたときに、こんなことを書いていました。

Google I/Oでこの方面でなんか発表してほしいなあというお気持ちの表明だったのですが、どうやら何も出てきませんでした。まあGoogleがやらないのであれば自分でやるしかない感じですよね…

とはいえ、何もないところから仕組みを作るのはいささか心もとないので、まずは5月にいじっていたLV2やlilvをAndroidに移植するところから着手しました(ホントはこれウソ入ってるんですが、まあ今全部書かなくてもいいでしょう)。

Androidサポートとは具体的にはFILEからの読み込みをAndroid Assetからの読み込みに変更するとか、direntを使ったディレクトリベースのプラグイン検索を無効化するとか(今回この実装を書いていて初めて気付いたのですが、Androidのassetはディレクトリ構造になっているくせにディレクトリのiteratorは無いんですね…)、ちまちまとした変更ではあります。しかし、元のライブラリが慣れないRDFやTurtleを扱うものなので勝手がわからない、慣れないCコード、ビルドがcerbero経由でしかなくgithubに確認なしでcommitしないとpullしてビルドできない、lv2の有用そうなやつをビルドするにはlibsndfileやcairoもビルドしなければならない、lv2まわりではcerberoが想定していないwafが使われている…といった諸事情もあって、割と手間取りました。たぶんここまでAndroid NDKでビルドできるようにしたやつ、cerberoのAndroid方面担当者以外では自分くらいしかいないのでは…

ソースはだいたいこの辺にあります。

LV2には、ドキュメントでも言及されている公式サンプルと、vst3sdk用のサンプルであるmdaというこの方面では割とポピュラーなもの(?)を移植したものがあり、今回Android向けにビルドした際にはこれらもビルドしています(正直プラグインはコードまで確認していません。どうせやっていることはオーディオバッファの加工くらいだろうし)。

今試験的に作っているサンプルアプリ(これはまだソース公開していない)をDeployGateで公開しているのですが(ってこのリンクもしかしたら無効なのかも…ログインしないと見られないのですが、わたしは自分のアカウントでしかログインできないので確認できない…)追記: やっぱ開けなさそうなのでとりあえずdropboxに一時うpしました、UIがまだメチャクチャで…まあそのうち使えるようになると思います(!?)。アプリ内のプラグインのスイッチを入れるとそのプラグインが適用されたwavの波形が描画されて再生もできるという感じです(再生は加工前のwavにもできるのですがwav表示がおかしい…)。また、このアプリ、諸般の事情からminSdkVersion 29なので、動作させることが出来る端末は世の中に1%も無いという…このへんは本番が出せる頃には改善したいと思っています(!?)。

また、これらのLV2プラグインをサービスとして列挙する仕組みまでは作ったのですが、それ以上は現状何もできていません。サービス側のスイッチを入れてもダミーのforeground serviceが開始されるだけです。

そんなわけで来月もこの辺のコードを書くことになると思いますが、忙しくなりそうなので手が回らないかも…とりあえず、LV2がAndroidで動く、というだけでも(当然プラットフォーム別に実装されるGUIのサポートは無くても)割と面白いんじゃないかと思います。ただもう少し一般化したいところではあります。LV2ほとんど利用例が無いので。

ちなみに、似たようなことをvst3sdkでも出来ないかと思ってビルドを試みたのですが、(X11なvstguiは論外として)Android NDK r20で実装されていないstd::experimental::filesystemLLVMではlibc++fs)に依存していたり、GCC固有の__gnu_cxx::__**atomic**_add()に依存していたり(Android NDKにはもうGCCが含まれていないのです)と、まだビルドできる状況ではないということが分かりました…(atomicはstd::**atomic**_fetch_add()で書き換えられそうではありますが)。幸いlibc++fsはNDK r21を目標に実装されそうなので、待っておこうと思っています。