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の利用場面を考えて、適宜非同期ダイアログを使うやり方に書き換えていきましょう。

CI環境でJUCEを使ってオーディオプラグインを適用する

JUCE Advent Calendar 2021、7日目のエントリーです。今年2回目。

最近MMLからTracktion Engineを使って再生できる楽曲をCI環境でMP3レンダリングするというややぶっ飛んだ試みで数日費やしていたのですが、どうやらうまくいきそうなので、何をやったのか共有します。

自分が理解している限りでは、CI環境でJUCEを使ってオーディオプラグインをホストして適用できた開発者は誰もいません。CIみたいな環境で利用するためにはJUCE本体にパッチを当てる必要があります。

今回はGitHub Actionsを使用していますが、本エントリーの趣旨としてはサービスはCircleCIでもいいしBitriseでもいいし何でもいいです。JUCEにどう手を加えるかがポイントです。

Why CI?

いま自分がやっている取り組みは、かつてMIDIで音楽データを打ち込んだものがそのままネットで流通して誰もが打ち込み技法を学び合い教え合えたような時代が、モダンなオーディオプラグインの世界でも実現できることを実証するという試みです。そのためには、楽曲データの生成をどこでも再現可能にする必要があります。打ち込んだ音楽がその作業環境になったPCでしかレンダリングできないようでは、これは実現できていないのです。

CI環境で音楽ファイルをWAVまでレンダリングできれば、その楽曲はおよそポータブルであるということができるでしょう。そしてこれは単なるMIDIファイルやチップチューンの表現力の範囲を超えて、一般的なDAWのワークフローでオーディオプラグインを使用した楽曲で、少なくとも同じ構造で再現できるところまで実現したいのです。

必要になるソフトウェア構成要素

(1) tracktion_engineのWAVレンダリング

12/9追記: これは「自分の」ソフトでCIレンダリングするために必要だったものを列挙しているだけなので、表題のような「JUCEでオーディオプラグインレンダリングをCIで動かす」ことだけが目的(たとえば自分のプラグインやホストをテストするとか)でレンダリングは自前で出来るというのであればTracktion Engineはいらないです。

Tracktion Engine、今さら言及するのは何ですがTracktion WaveformのシーケンサーエンジンとなるJUCEモジュールです。

github.com

今回やっているのはTracktion Engineに含まれるtracktion_engine::Renderer::renderToFile()というstatic関数を使ってtracktion_engine::EditオブジェクトをWAVファイルにエクスポートして、それをffmpegでWAVからMP3にコンバートするという作業です。Tracktion EngineではMP3などフォーマットを指定してエクスポートする関数も用意されているのですが、オーバーロードの提供方法が中途半端で(まあ誰も使わないから仕方ない)、必要になる引数をTracktion Engineのどこかから全部取得するか自前で全部生成するかみたいな二択になって面倒だったので、今回は使いませんでした。

(2) オーディオプラグインのスキャン

tracktion_engine::Editインスタンスは自分の楽曲ファイル(*.tracktionedit)をロードして生成していて、これはVST3インストゥルメントをフルに使った楽曲なので、オーディオプラグインがCI環境でロードできる必要があります。

Tracktion EngineはJUCEの上に成り立っていて、自身が保持しているjuce::AudioPluginFormatManagerに少なくとも一度はプラグインをスキャンさせないといけないのですが、これを実現できるのはjuce::PluginListComponentというGUIコンポーネントしかありません(あるいは「全て自前で」実装する必要がある)。

(3) オーディオプラグインのセットアップ

CI環境上に、juce_audio_proessorsjuce_audio_plugin_clientのモジュールがオーディオプラグインインスタンスを生成できるようなオーディオプラグインをインストールします。この時点でだいたい想像がつくかと思いますが、オープンソースでないオーディオプラグインはだいたい利用できなくなります。プラグインのインストールと利用にはstudiorackなどのソリューションが活用できるでしょう。

ここでひとつ重要なのは、自分のPC上で利用しているものと同じパスにあるプラグインを(現時点では)使わないといけないということです。同じバージョン・同じ規格のプラグインであればパスを調整する余地がありますが、今回はそこまで面倒を見ていられないので、パスを調整する仕事はしないことにします。そしておそらくstateバイナリを他のプラットフォームでも利用できるのは現状VST3とLV2くらいでしょう。今回はTracktion Waveformでもロードできる楽曲にしているので、(忸怩たるものがありますが)LV2は使わずにVST3で統一してあります。

(4) オーディオプラグインが使用する外部ファイルのパス調整

プラグインによっては、外部のファイルをロードしないと使えないものがあります。今回自分が楽曲で使用しているのはsfizzサンプラーですが、最新のsfizzバージョン1.1.1にはsfzファイルをユーザー指定のパスから探索できる機能が実装されています。楽曲で利用しているプラグインのstateには/home/atsushi/...みたいなパスが(不可避的に)保存されているのですが、実行環境に合わせてこのパスをrelocateできるわけです。sfizzのGUIにはsfzディレクトリ指定に対応するボタンがあって、この設定値は~/.config/SFZTools/sfizz/settings.xmlというファイルに保存されるので、CIではこれを事前に自動生成します。

プラグイン側にこのような機能が用意されていない場合、CI環境に制作環境と同じパスを生成してファイルを配置しなければならず、ひいては他人のPC環境で再生する場合にも同様の不都合がもたらされることになってしまいます。プラグイン側にこういったオプションを指定する機能が用意されていない場合は機能をリクエストしたほうがよいでしょう。

juce::PluginListComponentにheadlessサポート(もどき)を追加する

今回の一番の課題はjuce::PluginListComponentでした。このクラス、GUIとモデルの分離が全く行われていないのです。さらに、(ここが問題なのですが)このPluginListComponentで通常GUIから指示できるスキャン処理を外部から完全自動化する方法がありません。というのは、スキャンするプラグインがあらかじめリストで与えられている場合を除いては、まずスキャンするパスを選択するようなダイアログを出すような振る舞いになっているためです。関連コードはこの辺です。

GUIとモデルの分離が全く行われていないという観点では、そもそもjuce_audio_processorsモジュールがGUI部分を分離できていないという問題があります。この辺はJUCEの設計が現代から20年くらい遅れている(VST2の時代の設計に引っ張られている)感じがあります。VST3のモジュールアーキテクチャが前提だったらもっと違ったものになったんだろうなあ…

ともあれ、仕方ないので、今回はこのjuce::PluginListComponentを無理やりheadlessもどきにして、事前にheadlessもどきモードだったらこのダイアログをすっ飛ばして現在取得できるパスリスト(一般的にはプラグインフォーマット固有のデフォルトリスト)でスキャンを実行するようにしてみました。パッチはこんな感じです。

https://github.com/atsushieno/augene-ng-production/blob/main/juce-plugin-scanner-headless.patch#L1

使っているコードも(自分のプロジェクトに対するパッチですが)こんな感じです。

https://github.com/atsushieno/augene-ng-production/blob/d6fa60bd27833eb247d4c6a8b56f4ad02411be8e/augene-headless-plugin-scan.patch#L1

これはあくまでGUIコンポーネントでありこのインスタンスを生成しないと使えないのは正確にはheadlessとは言えないのですが、そもそもjuce_audio_processorsがheadlessではないので、CI環境では従来どおりX11をセットアップして実行する必要があります。これについては2年前に書いたやつを見てください。

atsushieno.hatenablog.com

できたもの

ここにあります。

github.com

GitHub Actions、ローカルで確認できないので完全に大変だった…ちなみに31トラック7分のMP3をレンダリングするのに60分かかります。レンダリング以外のタスクは15分もかからないので(15分も短くはないけどいざとなればバイナリパッケージを用意できる)、あとはTracktion Engine本体を最適化できるかどうかです。

余談

ちなみに現時点でJUCEのdevelopブランチにはこんな変更履歴があって(といっても見る日によって内容が変わるURLですが)、

f:id:atsushieno:20211207215602p:plain
history for juce_PluginListComponent.cpp

どうやら開発チームでも一度バックグラウンドでプラグインスキャンを実行できる仕組みを作ろうとして、VST3だとどうやらうまくいかないので「canScanOnBackgroundThread()でfalseを返す」ようにして、それじゃ全然使い物にならないと考えてか関数自体を削除する、みたいな動きを見せているので、やりたかったことなのかもしれません(逆にわたしのパッチでも回避できない類の問題なのかもしれない)。まあ使えるプラグインなら使う、というスタンスでいきたいですね。

正式なAPIとなったjuce::universal_midi_packets namespaceのAPIについて

JUCE Advent Calendar 2021、3日目のエントリーです。

JUCE Advent Calendar 2020で「JUCE vNext?に入りそうなMIDI 2.0サポートについて」という記事を書きました。導入としては悪くないのですが、まだリリース前のAPIということもあって、説明が割とふわっとしていました。2021年になってJUCE 6.1で正式にAPIに含まれたので、もう少し具体的な説明をまとめます。

docs.juce.com

MIDI 2.0自体はまだサポートするデバイスが登場していない状態ですが、macOS 11やiOS 14からはCoreMIDIにUMPサポートも追加されており、macOSとのネイティブ接続は理論上は可能になっているはずです。「理論上は可能」といえば、MIDI 2.0はMIDI 1.0の接続の上に成り立ちうるので(双方向接続は整備されている必要があります)、他のOSでもやればできないことはないでしょう(そこから実装する必要があるのか、という話になりますが)。なお、去年書いてからMIDI 2.0サポートを巡る状況は驚くほどほとんど何も変わっていません。AUAudioUnitにMIDI 2.0 protocolサポートが追加されたかもしれない程度です。

MIDI 2.0のパケットを実際のMIDI 2.0デバイスと送受信するかどうかは別として、MIDI 1.0では表現できなかったデータをMIDI 2.0 UMPの範囲で表現してみたい、とか、今OSが用意している範囲でソフトウェアMIDI 2.0シンセサイザーを作ってみたい、といった人は、このJUCEの新しいUMPサポートが便利かもしれません。

universal midi packetsとは

今回JUCEに追加されたMIDI 2.0のAPIはuniversal_midi_packetsすなわちUMPのみです。MIDI 2.0と呼ばれる仕様の中にはUMPの他にもMIDI-CI(これはMIDI 2.0以前に策定済)、Protocol Negotiation, Property Exchangeといった複数の仕様が含まれますが、これらに関連するAPIはありません(AppleのCoreMIDIにはMIDI-CIのサポートが含まれています)。

UMPは(筆者は既にMIDI 2.0 UMPを解説する同人誌でおおよそ「書き切った」上に去年も同じことを書いたしここでもたびたび解説しているのであまり繰り返したくないのですが)、これまでの「MIDIメッセージ」を刷新した仕様です。従来の仕様でいえば、80h〜8Fhでノートオフ、90h〜9Fhでノートオン、F0h〜FFhでシステム・メッセージ…といった部分です(えっ、FFhはメタイベントじゃないの?と思われたかもしれませんが、それは標準MIDIファイル(SMF)フォーマットの上での話であって、MIDIメッセージとしてデバイスとの間でやり取りされる情報としては、FFhはシステム・メッセージのひとつSystem Resetです)。

MMA (MIDI Manufacturers Association)ではユーザー登録しないと仕様書が読めない20世紀仕様ですが、日本のAMEIではユーザー登録無しで仕様書が(英文版で)読めるので、そちらがおすすめです。AMEIはわかっている…!

UMPはMIDI 1.0のメッセージ バイト シーケンスの構成とは大きく異なっています。MIDI 1.0の「メッセージ」は3バイトか2バイト、あるいはシステム・エクスクルーシブ・メッセージの「可変長」データでしたが、UMPの「パケット」は全て32ビット、64ビット、128ビットのいずれかです。UMPにはメッセージ種別があり、以下のように整備されています:

  • ユーティリティ・メッセージとMIDI 1.0チャンネルメッセージとシステム・コモン・メッセージおよびシステム・リアルタイム・メッセージは32ビット
  • 7ビットsysexメッセージとMIDI 2.0チャンネルメッセージは64ビット
  • 8ビットsysexメッセージと混合データ集合(mixed data set, MDS)メッセージは128ビット

そしてMIDI 2.0チャンネルメッセージは、多くのMIDI 1.0チャンネルメッセージで7ビットしか無かったデータ部分が32ビットに拡張されており、ピッチベンドも14ビットから32ビット、ノートメッセージのベロシティも(オン・オフともに)16ビットまで拡張されています。一方でMIDI 1.0の精度のデータをそのまま送受信したい場合は、UMPでMIDI 1.0チャンネルメッセージを使えば、従来どおりのデータ幅を使えます。いずれにせよ、これらは相互変換できることになっています(今回紹介する新しいAPIにも関係してきます)。

UMPサポートAPI ≒ バイト配列生成・変換API

MIDI 1.0やSMFを操作するコードを書いた経験のある開発者ならわかると思いますが、MIDIメッセージを構築するAPIというのは、とどのつまりは単なるバイト配列を処理するAPIであり、プログラミング技術として難しいことはほとんどありません。自分でも簡単に実装できるものばかりでしょう。とはいえ、落とし穴がいくつかありますし、JUCEのようなライブラリで誰かが実装していれば、それを再利用したほうが生産的といえるでしょう。ちなみに宣伝というほどでもないのですが、筆者も自前でCとKotlinのUMPライブラリを公開しています。

JUCEのようなオーディオ処理全般をカバーするフレームワークでは、MIDIバッファ処理APIにはリアルタイム処理に耐えうることが求められます。そのため、単なるバイト配列処理のAPIではありますが、メモリ確保を内部で一切行わない設計になっています。特に、変換結果として動的なサイズのデータ配列を確保しないように、変換結果がUMPであればメモリ確保が不要なコールバック関数によってメッセージを渡したり、部分配列のメモリ確保を避けてIteratorを使う、といった知見が、このAPIでは実装されています。

ちなみに、どの関数も機能としては単純ですが、目的がバラバラでその割に似たような変換関数ばかりたくさんあるので、APIリファレンスを見ていると一体どれを使えばいいのかサッパリわからなくなります。このエントリの目的のひとつはそれらの交通整理です。何をしたい時に何を使えばいいのか、少し見つけやすくなっていると思います。

UMPを扱う際にもうひとつ注意すべきは、7ビットデータが基本だったMIDI 1.0では考慮する必要が無かったエンディアンネスの問題が、32ビット整数を最小単位とするUMPでは発生しうるということです。UMPでやり取りして得られた整数値を、何も考えずにバイト配列化した上でファイル等に保存して、それをエンディアンネスに関する前提の共有なしでやり取りしたりすると、パケットデータが正常に復元できなくなります。

もうひとつ、これはAPI命名の仕方に関する問題ですが、MIDI1命名されていると、MIDI 1.0のメッセージ仕様に基づくバイトストリームなのか、MIDI 1.0チャンネル・メッセージを含むUMPストリームなのか、分からなくなることがあります。juce::universal_midi_packets名前空間には、UMPでないストリームをBytestreamという単語で命名しているものがあります。このあたりのドメイン用語はそこそこ紛らわしいので、気をつけながらAPIを使っていきましょう。

Packet<numWords>Factory

さて、ここからはJUCEのAPIについて各論的に説明していきます。APIリファレンスとソースを併読しつつ読んでもらえればと思いますが、ひとつ事前注意として、モジュールはjuce_audio_basicsjuce_audio_devicesに分散しているので、コードを検索していて「見つからないな…?」と思ったら探している場所を確認してみましょう。

juce::universal_midi_packetsでUMPのパケットをあらわす(strongly-typedな用途での)型はPacket<numWords>というテンプレートクラスです(UMPのPは「パケット」なので「UMPのパケット」と書くと馬から落馬みたいな響きがありますが、ここではわかりやすさを優先します)。C++のテンプレートの制約の使い方が高度なので、Packet<1> = 32ビットメッセージ、Packet<2> = 64ビットメッセージ、Packet<4> = 128ビットメッセージ、とだけ捉えておけばよいでしょう。

このPacket<numWords>を、各MIDIメッセージ種別、各種ステータスに対応して生成できる関数をもつのがFactoryクラスです。makeNoteOnV1()makeNoteOnV2()makeSysExIn1Packet()makeSysEx8Start()makeDataSetPayload()…といったメンバーがあります(sysexの"in one packet"って何?と思ったら規格書を見てみてください)。これでUMP仕様を諳んじていなくてもだいたいPacket<numWords>を作成できるでしょう。

UMPストリームを処理するときは、バイトストリームで処理するよりは、いったんこのPacket<numWords>のストリームに変換したほうが、ロジックを組みやすいでしょう。

int32_t*をUMP単位で切り出す

int32_t*によるストリームから実際にPacket<numWords>を構築するためには、その先頭のint32_t要素から、パケットが32ビットなのか64ビットなのか128ビットなのかを判別して、逐次変換していく必要があります。これを実装するためにIteratorViewというクラスが定義されています。

Iterator (const uint32_t *ptr, size_t bytes) noexcept
View (const uint32_t *data) noexcept

Viewひとつのパケットをあらわすクラスです。Viewにはbegin(), end()がありますが、これはそのパケットを構成するint32_t(1つ〜4つ)を反復するC++イテレーターとして使えるようになっています。IteratorクラスはこのViewを反復するもので、++で次の要素に進んだり*で現在の要素Viewを取得したりできるものです。

複数のPacketを生成してまとめたい場合はPacketsが使えます。内部的にはstd::vector<int32_t>を持っているだけで、あとは不必要なメモリ確保を行わないように事前にreserve()でバッファを確保する仕組み(というか前提)をもつシンプルなクラスです。PacketsPacket<numWords>Viewadd()で追加できますが、begin(), end()で返されるのはIteratorであり、従って各要素として返されるのはViewです。

Packetsでストリームを扱うときに注意すべきは、タイムスタンプを別途フィールドで持つようには出来ていないということです。UMPには、タイムスタンプに相当する情報を1/31250秒単位で指定するJR Timestamp (Jitter-Reduction Timestamp) というメッセージを含めることができます。タイムスタンプが必要なシーケンスをあらわすときは、このJR Timestampを間に挟むとよいでしょう。

MIDI1とMIDI2の整数相互変換とパケット相互変換

MIDI2は全体的にはMIDI1の上位互換を実現すべく策定されていますが、MIDI1とMIDI2ではデータ幅が異なるため、データを変換するには対象の値の範囲に合わせて変換する必要があります。基本的にはMIDI1からMIDI2への変換は次の通りです:

  • ノートナンバー、プログラムチェンジ: そのまま
  • コントロールチェンジ/RPN/NRPNのインデックスおよびバンク: そのまま
  • バンク・セレクトのMSB/LSB: そのまま
  • ベロシティ: 7ビットから16ビットへ
  • ピッチベンド: 14ビットから32ビットへ
  • データ部分: 7ビットから32ビットへ

MIDI2からMIDI1に戻す時はこの逆になります。プログラムチェンジやコントロールチェンジのインデックスなど、データ幅を減らしたら「!?」となるような項目は、MIDI2になってもデータ幅が拡張されていないので安心です。256チャンネルもグループとグループ内チャンネルが分けられているので、グループ0以外は無視する等の対応で十分でしょう。

以上をふまえて、Conversionクラスには次のようなstaticメンバーがあります:

  • scaleTo8() - MIDI1からMIDI2の8ビットデータへの変換…正直これを使う場面は思いつかないです。いらない気がする。
  • scaleTo16() - MIDI1からMIDI2の16ビットデータへの変換(ベロシティ)
  • scaleTo32() - MIDI1からMIDI2の32ビットデータへの変換
  • scaleTo7() - MIDI2からMIDI1の7ビットデータへの変換
  • scaleTo14() - MIDI2からMIDI1の14ビットデータへの変換(ピッチベンド)

MIDI1のViewとMIDI2のViewの変換

Conversionクラスには、MIDI2のViewをMIDI1のViewに変換(ダウンコンバート)する関数も定義されています:

これは関数テンプレートで、PacketCallbackFunctionをテンプレート引数としており、View1つを引数にとる関数(の右辺値参照)と説明されています。この関数がViewを生成するたびに呼び出されるコールバック関数です。このAPIが配列を動的にメモリ確保しないようにしていることを思い出してください。変換対象であるMidiMessageにはsysexメッセージが含まれ得るのですが、MIDI1ではsysexデータは可変長であるのに対して、UMPでsysexは64ビットあるいは128ビットの固定長パケットを複数含むかたちになります。これがいくつのパケットになるかわからないので、変換過程で1つひとつコールバックに処理させていくことで、動的なメモリ確保を防ぐというわけです。

これとほぼ対照になるもの、すなわちアップコンバーターが、Midi2ToMidi1DefaultTranslatorクラスのdispatch(const View &v, PacketCallback &&callback)関数です。…えっ? 何でMIDI2 to MIDI1はstatic関数だったのに、こっちはクラスのインスタンスを使うのでしょうか?? 実はこれには理由があります。MIDI 2.0 チャンネルメッセージには、RPN/NRPNをMSB、LSB、DTEとまとめて送信できるメッセージが規定されています(ステータスコード2nh / 3nh)。同様に、MIDI1ではコントロールチェンジで実現していたプログラムのバンク・セレクトは、新しいプログラムチェンジのオプションでまとめて送信できます。このdispatch()関数には、MIDI1側のこれらのメッセージを蓄積しておいて、必要な情報が全部揃ったらRPN/NRPNのメッセージやプログラムチェンジのメッセージを送信する、という機能が実装されています。これを実現するには変換過程をキャッシュしておく器すなわちインスタンスが必要になるのです。

MidiMessageからUMP Viewへの変換

JUCEで従来型のMIDIメッセージを表現するのはjuce::MidiMessageクラスです。juce::AudioProcessorMIDIバッファを受け取る時もこのクラスをjuce::MidiBufferから取り出して使ったりします。Conversion::toMidi1()関数を使うと、このMidiMessageをUMPのViewに変換できます。ひとつ留意すべき点として、toMidi1()には2つのオーバーロードがあります。

これらもmidi2ToMidi1DefaultTranslation()と同じく関数テンプレートで、引数callbackView1つを受け取るテンプレート引数です。

Conversionクラスとは別系統で、UMP1ConverterUMP2Converterといったクラスも定義されています。これらはMidiMessageまたはViewを受け取ってそれぞれ対応するViewに変換するconvert()関数を定義しています。また、GenericUMPConverterクラスはこの2クラスをまとめていて、同じconvert()を提供しつつ、コンストラクタ引数でPacketProtocol列挙値によってこれらを使い分けています。(このクラスにはコレクションの変換メソッドもあるのですが、まだコレクション変換の説明をしていないのでここでは省略します。)

これらはあくまで整数値や単一のパケットViewの変換なので( Midi2ToMidi1DefaultTranslatorは少々事情が異なりますが)、バイトストリームやUMPストリームの変換にはまた別の変換関数が必要です。

Dispatcher: Viewのタイムスタンプ付きイテレータ

複数のパケットViewを扱うクラスとしてIteratorPacketsを紹介しましたが、もうひとつ、uint32_tの範囲からViewに変換するDispatcherというクラスのdispatch()関数も利用できます。

template <typename PacketCallbackFunction>
void dispatch(const uint32_t* begin, const uint32_t* end, double timeStamp,
              PacketCallbackFunction&& callback)

このPacketCallbackFunctionは、JUCEのAPIリファレンスからは型定義を取得できないのですが(!)、Viewdouble (timeStamp)を引数として渡されるコールバックとなります。timestampは自分で渡しているので茶番っぽさがありますが、他のコードとの組み合わせで意味が出てくるものです。このDispatcherはこれから説明するストリーム変換で活用されています。

MIDIバッファのプッシュ型変換処理

juce::universal_midi_packets ではさまざまなストリーム変換APIが用意されています。数が多くてわかりにくいですが、分類していけば整理して理解できます。

ストリーム変換処理関数は、大別してpushMidiData()という関数とイベントリスナー的なレシーバーで処理するタイプと、convert()という関数によって処理するタイプがあります。ここでは便宜上、前者をプッシュ型、後者をコンバート型と呼びます。

プッシュ型の変換APIは、入力がvoid*uint32_t*であるかによって、大まかに2種類に分類できます。

  • (a) BytestreamInputHandler: バイトストリームvoid*を変換処理の入力とするインターフェースで、void pushMidiData (const void *data, int bytes, double time)が変換入力を処理します。
  • (b) U32InputHandler: uint32_t*ストリームを変換処理の入力とするインターフェースで、void pushMidiData(const uint32_t *begin, const uint32_t *end, double time)が変換入力を処理します。

変換出力側は、MidiMessageのストリームか、あるいはUMPのViewのストリームとなります。MidiMessageのストリームを受け付けるのはjuce::MidiInputCallbackで、Viewのストリームを受け付けるのはReceiverというインターフェースです。Receiverの場合はpacketReceived(const View &packet, double time)という関数を実装するかたちで、自分の行いたい処理を記述します。

  • (a.1) BytestreamToBytestreamHandlerは、juce::MidiInputおよびjuce::MidiInputCallbackを活用して、void*からMidiMessageストリームを生成します。
  • (a.2) BytestreamToUMPHandlerは(a)の実装で、コンストラクタ引数でPacketProtocolReceiverを指定し、pushMidiData(const void *data, int bytes, double time)で渡されたバッファを、BytestreamToUMPDispatcherを利用してViewに逐次変換して最終的にReceiverに渡します。
    • (a.2.1) BytestreamToUMPDispatcherは、uint8_tの範囲(begin/endのペア)とtimestampからUMPのViewを処理するcallbackに逐次処理させるものです。コンストラクタでPacketProtocolを選択してMIDI1かMIDI2かを選択可能です。
  • (b.1) U32ToBytestreamHandlerは、juce::MidiInputおよびjuce::MidiInputCallbackを活用して、uint32_t*からMidiMessageストリームを生成します。
  • (b.2) U32ToUMPHandlerは(b)の実装で、コンストラクタ引数でPacketProtocolReceiverを指定し、pushMidiData(const uint32_t *begin, const uint32_t *end, double time)で渡されたバッファを、Dispatcherを利用してViewに変換し、それをGenericUMPConverterで対象のPacketProtocolに変換して最終的にReceiverに渡します。
target MIDI1 MIDI2
message type MidiMessage View
void* input BytestreamToBytestreamHandler BytestreamToUMPHandler
uint32_t* input U32ToBytestreamHandler U32ToUMPHandler
event handler MidiInputCallback Receiver

MIDIバッファのコンバート型変換処理

最後はコンバート型です。

  • Midi1ToBytestreamTranslatorは、MIDI 1.0メッセージからなるuint32_tのUMPストリームをMidiMessageに変換します。
  • ToBytestreamConverterconvert()は、ViewとtimestampをMidi1ToBytestreamTranslatorによってMidiMessageに変換したものを、Fn&& fnに渡して処理させます(MidiMessageをそのままfnに渡すオーバーロード関数もあります)
  • ToBytestreamDispatcherdispatch()は、uint32_tのバッファ(begin/endのペア)とtimestamp値を受け取ってMidiMessageを処理するcallbackに逐次処理させます。その中では、Viewとtimestamp値をMidiMessageに変換するToBytestreamConverterが活用されています。

おわり

今年はJUCEのUMPサポートAPIが正式版になったので、具体的に詳しく1つひとつ見て解説してみました。

余力があったら筆者がktmidiやらcmidi2やらでさんざん書いてきたような変換コードをJUCEに持ってきたサンプルコードも書きたかったのですが、ちと別の終わってない草稿もかかえているので、また別の機会に…!

10月〜11月の活動記録 (2021)

月末恒例の自分用メモです。と言いつつ先月それらしいことを書いていなかったので(サークル情報で書いた気になってた)10月から…

MML + sfizz + VPO3のdogfooding

10月はM3の展示用の準備と展示告知がほとんどで活動記録らしきものを書いていなかったのですが、基本的にはM3の展示として作っていたRealistic SoundFont V2 + juicysfplugin (Fluidsynth) でフルオーケストラをTracktion楽曲フォーマットに変換して演奏するやつを実現するための実装やバグフィックスに奔走していました。

DAWシーケンサーエンジンなのでMIDIの16チャンネル制約は無く、フルに管弦楽曲として打ち込めたのですが、高音質のサウンドフォントとはいえMIDI音源らしいところが残っていたのはやや残念なところでした。このままだと単に「チャンネル数が多い(だけの)Fluidsynthで演奏してみた」以上のものになっているとは言いがたいので、改めてsfizzとVPO3 (Virtual Playing Orchestra3)というSFZを使用して、打ち込んだMMLに大きく手を加えてアレンジし直す作業をやっていました。完成品を聴いてもらえると分かるかもしれませんが、…

soundcloud.com

…VPO3はモダンなサンプラー用に録音されたデータで、キースイッチも活用して奏法を切り替えられるのを活用しています。fluidsynthでやっていたときは、これは単に音長を短縮するかたちで対応していました。

VPO3はフリーソフトウェア系のDAWユーザーの間では割と安牌なオーケストラ音源で、たとえばsfizzのサイトでは、フリーソフトウェアDAW系の有名打ち込み職人(?)のUnfaという人が、Ardour + sfizz + VPO3で音楽を打ち込むデモンストレーションをYouTube Liveで放送していた動画がリンクされています(長い動画ですが実質00:30:00くらいから1時間くらい)。

www.youtube.com

Unfaも動画中で解説しているのですが、フルオーケストラを打ち込むときはリバーブを(おそらく統一的に)適用するのが王道です(多分何を打ち込んでいてもそうだと思いますが)。フリーソフトウェアDTM界隈ではDragonfly-Reverbというのが割と知られていて(たぶん非フリーソフトウェア界でVarhalla DSPがポピュラーなのと同じくらい)、実はこの開発者がVPO3でオーケストラ楽曲を構成するときにこういうレイアウトにするといいよ、というArdourのテンプレートを公開しています。パンとステレオパニングとリバーブが設定されているようです。

github.com

Fluidsynth (juicysf)で打ち込んでいた時はパンを振るところまで手が回っていなかったので、sfizz版ではこのテンプレートに準じてパンを設定しています(Ardourを使うわけではないのでパートレイアウトだけ)。割とこの辺の知見を調べたりしているのは面白かったですね。

ちなみにDragonfly-ReverbはVST3フォーマットに対応しておらず、augene-ngでVST3のみ対応しているのと相性が合わず、今回は見送っています。これはプラグインSDKとしてVST3未対応のDPFが使われているためです(DPFには現在進行形でVST3サポートが追加されつつあります)。代わりにsuzuki kengoさんのSimpleReverbをビルドして打ち込んでいます。DSPとしての実装はほぼjuce::dspモジュールのReverbっぽいのですが、プレーンだとどうやってもうまく作れない銅鑼パートが「∞」 (Freeze) 機能でまあまあいい感じに出来たのでありがたく活用しています。

完成したトラックですが、当然ながら自作曲ではないものの、VPO3を含めフリーソフトウェア音源のみを使って打ち込んだ楽曲としては、かなり作り込まれたほうなんではないかと思っています。もうちょっとビルドの再現性を(誰でも同じプロジェクトソースから同じmp3/ogg/flacなどをビルドできるように)整備してから宣伝しようと思っています。ビルドの再現とはすなわちSTEMに留まらない全ソースの公開すなわちDTM環境の民主化であり、自分の開発活動の原点なので、地味だけど重要なトピックとして掘り下げていくつもりです(AndroidオーディオプラグインMIDI 2.0への関心も概ね似たような動機ですね)。

一方で「割とよく出来てるんじゃね?」と言いながらもホントはもっと調整したい項目がいくつかあって、特にエンベロープ、レガート、PAF (key pressure) あたりは音源のサポートが無いと難しいところです。どれもSFZとしてのopcodeはあって、SFZ側でそこまでカバーできているものが無いという感じで、フリーソフトウェア音源の限界は今のところこの辺にありそうです。VPOと同じくVSCO (Versilian Studio's Chamber Orchestra)のフリー版を採用しているORCHESTOOLSというHISE音源があって、これも試してみたいのですが、HISEのVST3ビルドのGUIが不安定で確認出来ない状態です。そんなわけで、この辺まで使い込んでみて、サンプラー開発の最先端課題のひとつに辿り着いた気持ちになっています。

何にせよ、コーディングではなく打ち込み作業に注力してそれなりのアウトプットが出せるというのは楽しいですね。もうちょっと意識的にこういう時間を確保していきたいところです(放っておくとコーディングに走ってしまうので)。

AAP: JUCEのエフェクトプラグイン

11月になって締切の類がなくなってゆとりが出てきたので、ひさしぶりにAndroid開発に手を出せました。今月新しく実現したのはaap-juceで長らく課題になっていた「インストゥルメントプラグインしか使えない」状況の解消です。

JUCEからのプラグイン移植はほとんどがシンセだったのですが(OB-Xd, Dexed, OPNplug, Odin2…)、エフェクトプラグインにもFrequalizer, ChowPhaser, 前述のSimpleReverbなどいろいろあり、ビルドだけはできていました。これはJUCEのissueがblockerだったのですが、先月「直ってるはず」とされたので改めてビルドして、自分のコード側のcrasherも修正して、もうひとつプラグインパラメーターをきちんとAudioProcessorに反映できていない問題も修正したら、ついに動くようになったのでした。

エフェクトプラグインが問題なく音声処理できるようになったので、割と使えるものが増えたのではないかと思います。ただAudioPluginHostでリアルタイムでInstrumentと繋いで発音させてみるとまだ遅延が割とあるので、やっぱりもうちょっと自作モジュールを見直したり軽量なホストアプリケーションを書いたりしてみないとな…という感じです(ホスト自作はめんどくさいのであまりやりたくない)。

ホントはここでスクショとか出したいところですが、MSIのメインマシンが1年4ヶ月にして死にかけていてビルドが今使っている旧メインマシンで残っていないので、別の機会に改めて列挙したいショゾンです。

UI統合を実装してみて外からプラグインの挙動を調整してみたいという気持ちもあるのですが、これは特にAndroid Sv2で複数のActivityが並列に動作可能になったことでAAP GUIアーキテクチャの再考も必要かな…となったり、その後Pixel Foldの撤回疑惑で一気にSv2の雲行きが怪しくなったので再考も先送りするか…となったりと、今月は情勢の変化に踊らされて止まっています。

ADC21

中旬にはADC 2021がロンドンとオンラインのハイブリッドで開催されていたので、夜中はこれに参加していました。現地参加できなくもなかったのですが、英国入国はともかく日本の入国がannoyingとしか言いようがないので見送りました(日本語で何て言うのが最適なのかわからない)。

細かく書ける無限の時間があれば書きたいところですが、特に面白かったセッションとしては(自分が聴けた範囲では) "Inside Modern Game Audio Engines" とか " Leveraging C++20 for Declarative Audio Plug-in Specification" あたりが良かったです(セッション個別のpermalinkが貼れないことに書いていて初めて気付いた)。まだ裏番組で見られなかったやつとかを見ていないので、他に面白かったのがあれば改めて書こうと思います。セッション動画はいずれオンラインで出てくることになっています。

buffering...

今月書けるネタが少ないな?と思ったところですが、よく考えたらテイルズ某とかやってた期間が空白なんだった…

12月は何かと書き溜めておかないといけないものが多いので下旬はそのために時間を使っている感じです(まあコレ自体もそう)。割と完成しないネタやボツったネタもあったりして、草稿箱の治安が悪くなっています。はよ手離れしてほしいけどそのために余計に書き物タスクの比率を増やしたくはない…

…とまあ今年はそんな感じで、ハードルの高いアドベントカレンダーも立てていません(去年もそう)。コーディングなり打ち込み作業なりに時間を使っていきたいと思います。