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

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

M3 2021秋 サークル出展情報

もう明日になってしまいましたが、M3 2021秋のcircle gingaサークル参加情報です(GINGAレーベルとは無関係です。って毎回書いている気がする)。コロナウィルスが今のところ大人しくなっていてこれじゃ夜逃げの言い訳にできねー、当初予想していたよりは物理会場にも参加しやすくなっているかもしれません。入場制限はきちんと行われるはずですし、当サークルもノーマスク・鼻出しマスク等に警戒しつつサークル参加します。

というわけで、今日は出展情報です。10/31当日は circle ginga (G12) にてお待ちしています。

新刊: MML to DAW via MIDI 2.0: 次世代MMLコンパイラ開発研究

今回も音楽制作は着手できませんでした…が、4月にC#からKotlinに全面移行してMIDI 2.0サポートも追加したMMLコンパイラmugene-ngを、JUEC AudioPluginHostのオーディオルーティングを組み合わせてTracktion EngineというかWaveformの楽曲データを生成するツールaugene-ngとして発展させていて、現実の打ち込み作業で使えるところまで成果を出せるようになりました。この動画は最近ずっと打ち込んでいた…というか、いる…augene-ngのサンプル楽曲です。

youtu.be

映像は工夫がなくてTracktion Waveform上で再生しているだけですが、このファイルはWaveform上では全く手を加えていない自動生成物です。Waveformユーザーならわかると思いますが(滅多にいないとおもうけど)、こんな緑一色の画面なんてフツー見ることがありません。コンパイルされたファイルには色設定情報が含まれていないんです。どうやらデフォルトは緑みたいですね。

ともあれ、そういうわけで、自分で作曲する代わりに著作権切れの管弦楽曲を使って、とりあえずはワークフローとして現実に作業できることを示そう、というdogfoodingをやっていました。先月もその作業成果としていろいろ知見がたまってきたので、M3ではこれを音楽技術書の題材にしてまとめておこう、と考えました。それで出来上がったのがこちらです…!

表紙(暫定)

表紙が…まだ…できてない…!! 現在鋭意執筆中なので、完成品には何かしらの表紙がつくと思います…つくといいな…。内容は60ページ弱くらい書いてあるので、たぶん今日図版を追加したらもうちょっと膨らむと思います。

内容としては主に、DAW(今回はTracktion)の楽曲データには何を含められるのか、MMLMIDI 2.0命令を出すにはどうするのか、MIDI 2.0の何をDAWに持ち込めるのか…といった話題をまとめています。Kotlinの話も1章くらい書いていますが、どんなライブラリを使っているか、みたいな話がメインでコードは出てきません。(ツールの使い方の説明をしたいわけでもないので、一応含めてはいますが、あまり期待しないでください…)

M3会場では、先のデモ音源とMMLの展示(たぶんモバイルデバイス…もしかしたら紙かも)と合わせて、この新刊(電子版)を500円くらいで販売する予定です。

目次も以下に2ページだけサンプルとして出しておきますが、最終版は変更される可能性があります:

目次1 目次2

追記: 表紙も出来た…!

表紙

旧刊同人誌・音楽CD(物理)

circle gingaの旧刊同人誌および音楽CDも少数部ですが当日用意しておきます。これら以外も含め、旧刊の電子版は当日に限らずいつでもboothおよび技術書典オンラインで購入できます。

デモ楽曲について

誰でも知っていそうなホルストの火星です。昔から打ち込んでみようと思っていたのですが、先月発売されたActraiser Renaissanceで遊んでいて、やっぱりコレでいこう…!ってなったのでした。(買ったけどまだやっていない、フィルモアまでしかやっていないという人は、とりあえず「世界樹」まで進んでみてほしい…わたしが何を言っているかきっとわかると思います。)

MMLIMSLPで公開されているパブリックドメインのフルオケ楽譜から30ページぶん目コピです。先週もDroidKaigiを聞いたりしながら裏で画質の悪い譜面とにらめっこしていました(後から聴いて直したところがたくさん…)。これだけパート数があると全部チェックするのも大変ですね。全パート聴いておかしいところを指摘する指揮者の仕事が大変なのがよくわかるやつ…

デモ楽曲は、今回は完成版とは言い難いところがひとつあります。使用している音源がsf2(サウンドフォント)で、オーディオプラグインとしてももこれをVST3としてロードしているjuicysfpluginなのです。フリー音源を使うのはこのaugene-ngのデモ楽曲として作成している以上は維持しておきたい制約なのですが(Kontakt等に逃げたくない)、もう一歩進めて、Virtual Playing Orchestraのsfzをsfizzから使用するバージョンの作成にも着手しています(augene-ngの目的は「今のところDAWでしか作られていないレベルのものをMMLでも作り出す」ことにあるので…)。ただ、音量もオクターブもその他全体的に不明な変更量の作業が約30トラック分あるので、これは今回は無理だと判断しました。

とはいえ、sf2でもRealistic Soundfont V2: Libre Editionという割と音質の良い音源を使っているので、ところどころおもちゃ感は出てしまうものの、20世紀のMIDI音源モジュールよりはずっと「それっぽい」音になっていると思います。

作業が現在進行形のsfizz版も含めて、MMLとプロジェクトのソースは全てgithubで公開してあるので、ソーセージの中身が気になる人は見てみてください。

9月にはayumi-juceなどを作っていたので、OPNplugと合わせてFM音源で何かやろうと思っていたのですが(実際ゲーム曲のコピー打ち込みなどもあるのですが)、こちらは自作曲でもないとgithubリポジトリで公開できないので、今回は見送りました。

recap

そういうわけで、10/31当日M3に来られる方は東京流通センターでお会いしましょう。 circle ginga (G12) にてお待ちしています。

Per-Note Expressionsサポートに関する覚書

調査の端緒

MIDIに変換するMMLで打ち込み作業をやっている時に、それぞれのノートの発音中に、それぞれのタイミングで音量を調整して打ち込みたいフレーズが出てきた。アルペジオ奏法みたいなことをシンセ音でやったことがあれば理解してもらえるかもしれない。たいていは単一のエンベロープで妥協しそうな気もするけど、特定のノートだけ違う力加減で発音したいような場合だ。

VelocityはMIDIノートオンのタイミングでしか指定できないから、VolumeかExpressionを使って実現するしかない(そのためのMML命令は実装してある)。しかしVolumeもExpressionもコントロールチェンジなのでチャンネルに帰属する。同じトラックで打ち込む以上は同じチャンネルで打ち込むことになる(理論的には別々のノートに別々のチャンネルを当てはめることは可能だけど、一般的なMML打ち込み作業でやるようなことではない)。

モダンなDAWとオーディオプラグインの世界では、MPE (MIDI Polyphonic Expression)とか、VSTのNote Expressionという、ノート毎にコントロールパラメーターを設定できる機能が存在している。MPEはMIDI 1.0で表現できる範囲内でこれを実現しているので、MPEをサポートする任意のMIDIバイスから、MPEをサポートする任意のDAWにデータを送ることが可能だし、その途中にどんなMIDI処理系が挟まっていても、メッセージを加工されない限りは大丈夫だ。AudioUnitでもAUAudioUnitsupportsMPEというプロパティが存在する。

そしてMIDI 2.0 UMPにはMPEの流れを汲むPer-Note ControllerやPer-Note Pitchbendといった機能が備わっている。これらを受信して処理できる「MIDIバイス」なら、これらのメッセージに対応して発音をノート別に調整できるはずだ。MPEで制御できるべきものとして規定されているのはピッチベンドとプレッシャー(アフター・タッチ)とCC 74(一般的にはカットオフ)くらいだが、MIDI 2.0 UMP仕様にそのような制限的な言及は無い(MPEでもその他をサポートするのは自由ではある。一部「推奨しない」ものもある。詳しくはここに書いた)。

VST3のNote Expressionは、MIDI 2.0 UMPにおけるPer-Note Controllerに近く、サポートされるノート別パラメーターについて具体的な制限は無く、プラグイン側が任意に規定し、ホスト側はそれをユーザーに提示するだけのようだ。

いずれにせよ、現時点でどこまで自作ツールで「実現」できる機能なのか把握しておきたかったので、関連仕様とサポートの実態を調べることにした…というのが半分で、残り半分くらいは日頃の調べ物を少しまとまったかたちで記録しておこうと思ってのことだ。

MPE入力の手法

ここでは話題の対象を既にDAWで実装例がいくつもあるMPEに限定するが、他のPer-Note Expressionについても当てはまる話ではある。

MPEは特定のノートに対してコントロールチェンジ等を送るもので、その入力は一般的なコントロールチェンジのようにトラック毎に入力させることはできない。ノート毎に入力させる必要がある。少なくとも、特定の音階に対して入力させる必要がある。常にあるノートでノートオンするたびに毎回同じコントロール値を期待するという状況は、ユースケースを考えてもあまり一般的ではなさそうだ。常に固定の相対ピッチで微分音作曲するなら、MTS (MIDI Tuning Standard) に則って設定したほうが適切で、オーディオプラグイン(インストゥルメント)の場合は微分音作曲に対応している場合はscalaファイルをサポートしているのが一般的だ。

自分が知っているところでは、Audio Developer Conference 2019でSteinbergの開発者がBitwig Studio、Cubase、ReaperでそれぞれどのようにMPEを入力するか紹介しているので、興味があれば参考にされたい。

youtu.be

この後ちょっと話に出てくるTracktion Waveformでもこんな編集画面がある:

MPE on Tracktion Waveform11

(ここでは何度も紹介しているけど、Tracktion Waveformは音楽編集・再生部分がtracktion_engineとしてGPLv3で公開されているので、次に言及する自作ツールでも活用している。)

TracktionのMPEサポート

自分のMML to Tracktionコンパイラaugene-ngでは、MMLコンパイラmugene-ngを使っていったんSMFに変換した上で、Tracktion Waveformで編集できる && Tracktion Engineで再生できるような楽曲データ(*.tracktionedit)に変換するものだった(過去形)。今回まとめている内容を実践する過程で、SMFからMIDI 2.0の楽曲データに移行している(mugene-ngでは既にMIDI 2.0楽曲データの生成をサポートしている)。そして、MIDI 2.0のPer-Note Pitchbendを、*.tracktionedit上ではMPEとして変換されるように実装した。

ただ前述の通り、MPEで標準的にサポートされているのは、ピッチベンドとアフター・タッチとCC #74(一般的にはカットオフ)のみであって、たとえばCC #07 (Volume) やCC #0B (Expression) に相当するものを、MIDI入力デバイスから送ることは出来ても、MPEのホスト側が受け付けないことはある。

今回は、VolumeやExpressionをPer-Note ExpressionとしてMMLで表現してUMPに変換して、それを*.tracktioneditに出力したいと考えた。しかし、Tracktionでは、MPEで送られてきた情報は、<NOTE>要素の子要素として、<PITCHBEND><PRESSURE><TIMBRE>のいずれかのみ保存されるようだ。つまり、Tracktion側にはNote Expressionを受け入れる場所がない。WaveformのGUI上にもこの3つの選択肢しか無いし、tracktion_engineの関連しそうなソースコードを見ても、この3種類しかサポートしていないようだ。

JUCEとNote Expressionサポート

そもそも、Steinbergのスタンスとしては、Note Expressionサポートはオーディオプラグインホスト側で実装されている必要がある。Tracktion EngineはJUCEで実装されているので、juce_audio_plugin_client(オーディオプラグインホスティング用モジュール)がVST3のNote Expressionをサポートしていなければ、Tracktion Engineが独自にサポートしている可能性は限りなく低い。そして実際JUCEではNote Expressionをサポートしていない。一方でJUCEはMPEのサポートには積極的だ。MPEは基本的にMIDI 1.0をサポートしていればあとは送り手と受け手の問題(中間層はただ転送するだけ)ということもあって、幅広く受け入れられている。

JUCEがNote Expressionをサポートしないのは、Note ExpressionがVST3固有の機能でしかない(AudioUnitでサポートされていない)からだと言えそうだ。もっとも最新のCoreAudioに含まれるAudioUnitではMIDI 2.0をMIDI入力として受け付けられるようなAPIを持っているように見える。もしそうだとしたら、juce::AudioProcessor::processBlock()MidiBufferがUMPを受け渡しできるように機能追加できるかもしれない。いずれにせよJUCE本体とTracktion Engineの両方に手を加えないと実現出来ないレベルの話だ。

VST3 MIDI Outputの問題

仮にJUCEに手を入れてMIDI 2.0をホストからサポートしたとしても、MIDI 2.0をサポートするMIDI出力プラグインの扱いが割と厄介な問題になる。主としてVST3がMIDI出力をまともにサポートしていないということが批判的に言及されることが多いようだ。入力をVST3のIMidiMappingでVST3のイベントに置換する時点でもイベント順序がノートとコントロールの間で保持されないなど致命的な問題があったようだ。このKVR Forumのスレッドの1ページ目の最後によくまとめられている。

www.kvraudio.com

JUCE ForumでもMIDI to MIDIプラグインまともに機能しないの?というスレッドがある。VST3のMIDI出力プラグインはbastardなどと言われている。もちろんjuce::AudioProcessorにproducesMidiというプロパティが在る程度には一般的な機能だった。

forum.juce.com

VST3のNote ExpressionとMPEどっちがどう良い?みたいな議論は割と昔からあるようだ。ここではBitwig StudioのKVR Forumの議論をひとつ紹介するにとどめておこう。

www.kvraudio.com

MIDI 2.0はNote Expression以上にサポートされていないが、Steinbergに手綱を握られること無く幅広く互換性が期待できるという意味では(そしてすでにAudioUnitでサポートされているいそうな様子からも)、今後はMIDI 2.0サポートを介在してのNote Expressionサポートが期待できそうではある。

LV2とMPE

VSTではNote Expression、AUではMIDI2の採用によってPer-Note Expressionが実現していそうだが現実的にはMPEしか出来ていないっぽい、ということはわかったが、LV2ではどうだろうか。実のところLV2ではMPEサポートも危うく、現実的にはLV2をサポートしているホストでMPEをサポートしているものは無いかもしれない。Ardourの場合は「MPEをサポートしようと思ったら根本的な再設計が必要だ」というのでサポートしていないようだ。

discourse.ardour.org

QTractorも「MPEはMIDI 1.0に基づいて入力できるでしょ」というレベルでサポートしていないようだ。

www.rncbc.org

この意味ではLV2を後からサポートするようになったReaperなどのほうが期待値が高いかもしれない(ReaperのLV2サポートの現状はちょっと検証できていない)。

MPEとサンプラー

話をホスト側からプラグイン側に移そう。今回自分が使いたかったのはFluidsynthやsfizzなどのサンプラーだ。Fluidsynthは良くも悪くもSF2ファイルをもとにMIDIソフトウェア・シンセサイザーとして動作することだけが考慮事項で、ここでは何度か紹介しているがMIDI 2.0を部分的にサポートしている。ただ、今回機能してほしいのはPer-Note Controllersで、これはまだ実装されていないようだ

sfizzのようなSFZサンプラーの場合、話はもう少しだけややこしくなる。というのは、SFZはもう少し細かいサンプラーの制御をSFZファイル側で記述する側面があるので、sfizzだけが独自に機能拡張できる話ではないからだ。開発者コミュニティにおける議論をざっくり検索して眺めてみた限りでは、SFZでもノート別にコントロール・チェンジの挙動を細かく記述することは可能なようだ。ただし、SFZのポピュラーな商用サンプラーSforzandoも含めてそれらをサポートしているものは見当たらないようだ。

github.com

LV2にMPEサポートが(仕様面にしろ実装面にしろ)無さそうなこともあって、sfizzなどのSFZサンプラーでMPEやNote Expressionがサポートされる日はまだまだ遠いかもしれない。

とはいえ、おそらくsfizzの開発者もこの辺のサポートが手薄であることを気にしているのではないかと自分は想像している。sfizzの主要な開発者の1人が今年公開していたのが、MPEをサポートするJUNO-60クローンだった(JUCEで作られている)。これは既存のシンセサイザーにMPEを組み込むのを試してみた側面があるように見える(READMEの冒頭で言及している)。

github.com

仕様上はPer-Note Expressionの実現を妨げるものがないとしたら、MIDI 2.0をサポートするプラグインホストが出てくれば、サンプラーがPer-Note Expressionをサポートする動きが出てくるかもしれない。

ちなみにnkiフォーマットはKontaktがそもそもVST3に対応していないレベルなので無理ではないかと思う(出来てMPEサポートくらい?)。

9月の開発記録(2021)

月末恒例9月の自分用開発記録です。といっても今月はコーディング作業はそんなに注力していません。先月の終わりに

MMLでまた打ち込みできる環境を何とかしたい…

などと書いていたように、状況で言えば2019年1月とか2019年10月とか2020年10月くらいのノリで、M3に何か出せるものを作れる環境を構築しようとあくせくしている感じです(この時点で環境構築とか言ってるのは既に無理そうな気がする)。

ayumi-juce

先月augene-ng(相変わらずこの名前がいつまで続くのかは未定)でオーディオプラグイン + MMLの制作環境がある程度現実的になってきたので、ためしにOPNplugのYM-2203 (FM) + ayumiのYM-2149 (PSG) の組み合わせで打ち込みを試しています。ただ、これを現実にするためには、実はひとつ追加作業が必要でした。ayumi-lv2は作ってあってaugene-ngでも音は出せたのですが、製品版のTracktion Waveform11ではLV2はサポートされていないので、VST3版を作る必要が生じてしまいました。

augene-ngが前提とするAudioPluginHostがVST2をサポートできていればそれでもいいのですが、そうでない環境ではVST3のみがWaveform11との共通解になってしまっている状態です。ayumiは、別にわたしが手を出さなくても、zynayumiというプロジェクトがあって、そっちのほうが完成度がはるかに高いのですが、これは内部的にはDPFを使用していて、DPFはVST2(VeSTige)やLV2をサポートしている一方でVST3はまだ開発中のステータスで、VST3ではビルドできない状態です。そっちを何とかするよりはayumi-lv2をJUCEでビルドしたほうが早いだろうと判断しました。

そういうわけで新しいリポジトリが生えています。

github.com

今回はついでにVSTパラメーター経由でayumiの各種コントロールを調整できるようにして、ついでにソフトウェアエンベロープを実装したので、パラメーター数がだいぶ増えました。エンベロープはADSRではなく制御点の直接指定で、UIは用意しておらず、あくまでパラメーター設定UI(JUCEの汎用部品)で調整する感じです。正確にはrelease部分が未着手です。JUCEのSynthesizerに統合してJUCEべったりにしてしまうかどうかが悩みどころ…

compose-mpp

https://zenn.dev/atsushieno/articles/compose-mpp-library に書いたので詳しくは省きますが、kmmkやaugene-ngのaugene-editor-projectでいつまでもAlertDialogやDropdownMenuが使えないのは不便だったので、JetBrainsがやらないなら自分でやってしまえということで作りました。割とちゃんと調べないと作れないやつだったので、シンプルな実装の割には試行錯誤を重ねていたり…

github.com

MMLのオートメーショントラック対応

今月はほとんどaugene-ngで打ち込み作業をdogfoodingして過ごしていて、その過程でいろいろ旧C#版から存在していた問題にいくつか改善を施すことができました。

今月最初に着手していたのは、MMLからオーディオプラグインのパラメーターを更新する手段の実現です。 tracktion_engine ではオートメーション トラックを使用すると、そのトラックに紐付けられたプラグインのパラメーターの値を変更できるので、MIDIでコントロールチェンジを送るのと同じ感じでMMLに記述できるようにしたい、と考えました。

ただし、MIDIにはオーディオプラグインパラメーターという概念が存在しないので、何かしら別のコンセプトをMIDIメッセージの枠組みに割り当ててやる必要があります。いくつか候補を考えましたが、最終的には無難にMMLからはsysexイベントとしてコンパイルさせて、Midi2TracktionEditConverterでそれを検出してAutomationTrackのパラメーター設定あるいはパラメーター値変更としてXML要素を生成することにしました。

MMLとしてはどんな感じで記述するのかというと、READMEからのコピペになるのですが、こんな感じです:

#macro AUDIO_PLUGIN_USE nameLen:number, ident:string {  __MIDI #F0, #7D, "augene-ng", $nameLen, $ident, #F7 }
#macro AUDIO_PLUGIN_PARAMETER parameterID:number, val:number { \
    __MIDI #F0, #7D, "augene-ng", 0, \
    $parameterID % #80, $parameterID / #80, $val % #80, $val / #80 } 

#macro OPNPLUG { AUDIO_PLUGIN_USE 11, "-1472549978" }
#macro OPNPLUG_MASTER_VOLUME val { AUDIO_PLUGIN_PARAMETER 0, $val }
#macro OPNPLUG_EMULATOR val { AUDIO_PLUGIN_PARAMETER 1, $val }
#macro OPNPLUG_CHIP_COUNT val { AUDIO_PLUGIN_PARAMETER 2, $val }
#macro OPNPLUG_CHIP_TYPE val { AUDIO_PLUGIN_PARAMETER 3, $val }
#macro OPNPLUG__PART_\1__OPERATOR_\1_LEVEL val { AUDIO_PLUGIN_PARAMETER 4, $val }
...

ユーザーはMMLOPNPLUGと書くことで、そのMMLのトラックでこれから出力するオートメーションパラメーターがOPNplug(としてローカルシステム上にプラグインのUIDが-1472549978であるもの)のものであることを指定し、OPNPLUG_MASTER_VOLUME 8191 と記述することで、そのmaster volumeに8191を指定します。こうやって一度MMLマクロとして定義してしまえば、あとはさらに短く記述したMML命令(たとえばOPN_MVとかにしても良いです)を独自に定義できます。

これはローカルシステム上にインストールされているオーディオプラグインのそれぞれについて、ひと通りだけ定義してしまえば良いので、全てのプラグインをスキャンしてこれらのオートメーション用MML~/.config/augene-ngディレクトリ以下に自動生成するツールを作成して、ひと段落としました。

MMLコンパイルからのHot Reload

augene-ngの実用上の最大の問題は、昨年書いたことなのですが、オーディオプラグインがいくつもロードされるような楽曲になってきたときに、augene-playerが楽曲をロードするのに数十秒〜分単位で時間がかかってしまうことでした。augene-ngのコンパイラ…というべきなのかビルダーとでもいうべきなのかはわかりませんが…は*.tracktioneditファイルを生成するので、これをシンプルにtracktion_engine::Editとしてロードしていると、そこでオーディオプラグインの再ロードが行われ、そこで時間を食う…というのが問題の原因です。

このAPIで楽曲をロードしている以上はこの問題は避けられないようなので、楽曲全体のリロードを避けて、差分だけ更新するというのを基本方針として改善を試みました。全体をデプロイしたら時間がかかりすぎるので、必要な差分だけをデプロイする…そう、Instant Run、Fast Deployment、Hot Reload、Apply Changesです…! まだまだ完全ではないのですが、とりあえずHot Reloadのオプションをaugene(-editor)に付けて、Hot Reload有効時にはMidiClipの内容を書き換えるだけ、とすることで、打ち込み作業中のロード時間を大幅に短縮できるようになりました。*.tracktioneditが更新されるだけで自動的にリロードするオプションもあるので、リロードまわりのストレスは大幅に減少したと思います(といっても現状使っているのは自分だけですが)。

audio plugin portaibilityのある音色バンク

もうひとつの大きな問題は、DAWで打ち込んだデータがローカルシステム上のプラグインのセットアップに依存するのが不可避であるというもので、これも昨年書いたやつです。去年は主としてAndroidとの移植性と楽曲データ(augene-ngでいえば.tracktioneditファイル)そのものの移植性の観点からの困難を論じましたが、これは課題として難易度が爆上がりなので、今年はまず異なるデスクトップ環境間での「ソース」移植性の課題として解決してはどうかと考えています。

augene(C#版)開発時は、その末期に、SMFのMETAイベントのひとつInstrument Name(メタイベント04h)を使って、ここにAudioPluginHostのfiltergraphのエイリアスを指定する、という手法を編み出しました。これを実装した当時は、単にプロジェクトファイル上で<AudioGraph>要素のIDを<Track>要素で紐付けるのが面倒だったのと、複数のトラックで1つのプラグイン設定を使いまわしたかったというのが理由です。しかし、いったんmugeneのINSTRUMENTNAME命令(メタイベントを生成するマクロ)でプラグインを間接的に指定するというスタイルは、プラグインエイリアスを与えて、いったん環境依存のファイルパスなどから引き離している、という特徴もあることに気が付きました。

augene(C#版)には<Include>要素のサポートも実装してあって、これを使うと他のプロジェクトファイルをインクルードすることも可能です。このインクルードされるファイルとして、オーディオグラフの集合体を定義しておけば、楽曲ごとに自分のプリセットを定義してまわる必要がなくなります。そして、その中で指定される*.filtergraphの内容が、プラットフォームごとに and/or 環境ごとに調整されていれば、これをインクルードする側は何も変更しなくても移植性のあるプロジェクトになります。またそれらをINSTRUMENTNAMEマクロで参照しているmugene MMLの記述も環境からは完全に独立しています。

MMLによるオートメーションサポートも合わせて、これはもしかして、不完全ではあるかもしれないけど、割とプラットフォーム独立性を担保できたのではないか…!?と思っています。それでaugene-ngのリポジトリにサンプルとして手持ちのオーディオプラグインを使ったfiltergraphファイルから、フリー音源とフリーソフトウェアプラグインのものをいくつか、<Include>できる*.augeneプロジェクトとして放り込んでみました。実際にはvst3プラグイン*.sf2/*.sfzといったファイルを/opt/augene/みたいなパスに突っ込まないといけなそうですが(~/.vst3などを指定していると結局その/home/atsushiみたいなディレクトリにマッピングされている気がします)、このアプローチで誰でも使い回せるようになれば悪く無さそうです(Windows/opt/augeneを作るのは無理そうなので、別の定義ファイルが必要になりそうですが)。

この観点ではstudiorackプロジェクトがいくつかクロスプラットフォームでのオーディオプラグインセットアップを自動化する試みを開発しているようなので、もしかしたらそのうちこの辺を使い回すかたちでより具体化した実装が進められるようになるかもしれません(未検討)。

SF2やSFZのファイルからも、これらの*.filtergraphファイルを自動生成できると良いのですが、どうしてもVST3フォーマットのstateバイナリの生成が必要になってしまうので、それなりに込み入ったハックをしないと実現しなさそうです。

going forward

augene-ngには他にも自分が便利に使いたいために施した改善がいくつかあるのですが、総じて技術的可能性と実用性の検証のために行っているという感じです。以前よりはだいぶ地に足が付いた感じになってきて(いやまだまだふわっとしているのですが)、もうしばらくこの路線で遊んでみてもいいだろうという気持ちになっています。フツーこんな他に誰も使わなそうなPoCを続けることって無いと思うのですが、似たような感じで10年くらいずっと1人で続けていたっぽいBespokeSynthが(初見はたぶんtheaudioprogrammer meetupだったと思うのですが)ついに完成して各所で話題になっているのとかを見ていても、何か励みになりますよね。まあこちらはまだMIDI2の活用事例にもなりそうですし(まだMIDI1なのですが、今やっている打ち込み作業で16チャンネルを使い切る前に対応しないといけなくなりそうな気がする)、もうひと月くらいはAndroidのほうも放置してこっちに注力しておこうと思っています。

8月の開発記録 (2021)

8月は主にKotlinに移植してきたMIDIツール・ライブラリの機能を回復・強化する1ヶ月になりました。他の開発作業(Androidオーディオプラグインとか)はあんましやっていません。「回復」というのは.NET時代にmanaged-midiで作ってきて打ち込みで使ってきたものと同等以上の機能を埋める (filling the gap) 的な意味です。

技術的には無駄にKotlin MPPとKotlin Nativeの知見が貯まった気がします。先月はKotlin/JSの知見が貯まったし、音楽アプリ開発者志向のはずなのに無駄にKotlin開発者になりつつある気がする…

kmmk 0.2.2: Fully featured virtual MIDI 1.0/2.0 keyboard application using Kotlin and Compose for Desktop and Android

先月にも言及しましたが、7月末にJetpack Compose 1.0 stableが出たこともあって、8月のうちにはCompose for Desktopもそれなりに安定的に手を出せる状態になるだろうと思っていましたが、本家のリリース後ほどなくcompose-1.0-rc1-alpha1というアルファなのかRCなのかよくわからんバージョンが出て、とりあえず1.0ベースのパッケージ体系が早々に確立されていました。

そういうわけで、半年くらい前にざっくりCompose for Desktopで実験的に実装していた(まあまだ実験的なUIの色が強い)仮想MIDIキーボードkmmkですが、今月はだいぶ機能強化しました。もうxmmkを使う必要はなさそうです。

Compose for DesktopのGradleタスクもいろいろ強化されていて、./gradlew packageなどと実行するとrpm/deb/msi/dmgなどがビルドできるようになっています(こんな感じでbuild.gradle(.kts)に記述します)。デスクトップのライフサイクルなども考慮したDecomposeも安心して使えるようになりました*1

kmmk

アプリの機能面では、「とりあえず音が出せる」レベルだったものに、キーボード配列の調整機能(US ASCIIとかJIS 106とか)を選択できたり、音色(プログラムチェンジ)を選択できたり、あと作曲時には有用なクロマチック配列モードを選択できたりするようになりました。クロマチックモードは特にキーボードを適当に打っていても普段ピアノ配列のキーボードでは出ないようなコードがカジュアルに出てくるのと、指をそのまま移動するだけで調性を維持できるのが便利だったりします。

それからMML Padへのレコーディングモードをxmmk(C#版)から復元して、ついでに今回はテンポに合わせて音長を記録するモードも実装しました。すでにアルペジオ奏法を含む和音の入力をサポートしているところに追加したので、それなりに動作の調整を必要としましたが、概ね想定通りの動作になっています(まだ実際の打ち込みでは使っていませんが)。

UIに関しては、C#時代にはまともな複数行テキストボックスを入力できるコンポーネントが無かった(&& xwt/gtk3がまともに動作する状態にならなかった)ので、SwingベースのCompose for Desktopに移行するだけで基本機能が大改善しています。一方でCompose for Desktopは未だにJetpack Composeの標準コンポーネントにあるDropDownListが使えなかったりする状態なので*2、まだ真面目にアプリUIを作り込もうと思ったら避けたほうが良いでしょう。

キー配列のグリッドはクリック/タップ用に残しているのですが*3、見づらいのでキーボード表示を別途追加するかもしれません。ドラムパートの編集も可能になるように10ch.スイッチも追加してあります。Android Phone上でMMLテキストが扱いやすいとは考えにくいので、Androidでの用途は主にMIDI音源の動作確認になりそうです。

augene: ntracktive -> kotracktive -> missing-dot: a migration helper library from .NET to Kotlin MPP

managed-midiで作り上げてきたプロジェクトのひとつにmugene MMLコンパイラをtracktion engineと組み合わせてオーディオプラグインを使った楽曲を直接打ち込めるようにするaugeneというものがありました。今年の春先にktmidiプロジェクトを立ち上げたときに「ここまでは実装しておきたい」と思っていたプロジェクトで、4月まででは進められなかったものです。

これは原理的にはSMFからtracktion engineの.tracktioneditという拡張子のXMLを生成しているだけなので*4、Kotlinにも簡単に移植できるだろう…と思っていたのですが、これが意外にも大変でした。

C#版ではntracktiveというライブラリとして.tracktioneditファイル生成部分を実装していたのですが、データモデルはXMLのレコード構造に対応するシンプルなクラス、読み書きはそのクラスからリフレクションでメンバーを取得してXmlReader / XmlWriterで機械的に実現、というオレオレシリアライザもどきの実装でした。まずはこのライブラリを"kotracktive"としてKotlin MPPに移植しようと考えました。

これをKotlinで実現するには、.NETの2つのカテゴリのAPIが必要になります:

  • System.Xml(.ReaderWriter)
  • System.Reflection

これらを実現する手段が、実はKotlin MPPには全くありません(!!)

XML自体が技術的トレンドではないということはありますが、KotlinでXMLを使う場面では、いつもKotlin/JVMに基づいてXmlPullやらSAXやらDOMやらが使われています。シリアライザまで概念を高位にすると、MPPでもkotlinx.serializationでXMLを読み書きするという手段が登場するのですが、そのXMLフォーマットサポートとして使えるとされるpdvrieze/xmlutilではKotlin Nativeがサポートされていません。バックエンドもプラットフォーム側のネイティブ実装に無理やり合わせているフインキがそこはかとなくあって、ネイティブ密着型クロスプラットフォームUIツールキットを使うときのような不安感があります。

どうしたものかしばらく考えましたが、悩んでいる暇があったらXmlTextReaderとXmlTextWriterに相当する機能くらい、ざっくり実装してしまおうと思い立って、合わせて2日くらいで適当にやっつけました。その後この辺を抜き出して独立したライブラリとしてmissing-dotというパッケージで公開しています。MPPでjitpackから拾える親切設計…! *5

github.com

XmlTextReaderはちょっと面倒なところがありますが、それでもDTDと実態参照をサポート対象外にして、CDATA Sectionの]]>の対処を先送りしてしまえば、面倒はかなりなくなります。XmlTextWriterも、とりあえずWhitespaceノードやらインデント対応やらが未サポートですが、.NETの実装とは異なりXSLT 1.0のややこしい要求事項を先送りしたので、とりあえず無難に使えます。しかもnamespacesプロパティ対応も付けてあるので、JUCEで生成されたNamespaces in XML違反のXMLすら読み書きできるヤツになっています。XmlNamespaceManagerは実装し、XmlNameTableは実装しない、くらいのバランス感覚です。

ホントは最初XmlPullを適当にJVMから移植しようと思っていたのですが、リフレクション依存がしんどそうだったのでやめました。この話は実は次の話と続いています…

System.Reflection APIの物真似をkspで実現する

XMLの読み書きは実現できましたが、Reflectionのほうは大変です。まずリフレクションの基礎となる実行時型情報がKotlin MPPで使えるkotlin.reflectにはありません。実行時に操作できるオブジェクト (Any) からはクラス名が取得できる程度です*6。クラスのメンバーを取得できるKDeclarationContainer.membersなどはJVM onlyです。System.Type.GetType(String name)に相当する機能を実現するには、ビルド時に生成できるコードだけで型情報データベースを作っておく必要があります。

Kotlinの世界では、こういう時はリフレクションではなく補助的なコード生成によって、メタデータをもとにコードを生成してリフレクションの代わりに用いるのが一般的であるようです。それならばSystem.ReflectionAPIのような「ランタイム」を作って、それらを「実装」するコードを動的に生成すれば出来そうだ…と考えて、今年Kotlin compiler pluginとして登場したksp実装しました。KSP...Kontakt Script ProcessorではないKSP…(ややこしい)。生成されるコードはたとえばこんな感じです。

ランタイム的なMetaTypeTypeJVMとかでややこしくなるので改名)やらTypeCodeやらPropertyInfoやらの実装はこんな感じです。これは先のmissing-dotに移動すべきか迷って結局まだ入れていません。

kspそのものは難しいことはあまりないのですが、kspのモジュールをプロジェクトに組み込む過程で、プロジェクトの構成そのものをkspとksp適用モジュールだけのKotlin MPPプロジェクトだけに絞り込んで再構成する必要が生じてしまいました。Kotlin MPPのモジュールは、特に制約が無ければ1つのプロジェクトに複数組み込むことも出来なくはないのですが、やはりプロジェクト全体を見に行って無関係なモジュールにも影響を与えるようなプラグインなどが関わってくると、想定外のビルドエラーを起こすようになったりします。

この種のビルドエラーの一部は、他のライブラリやプラグインを追加してもよく起こります。同一の入力に基づくビルドは常に同一の結果を返さなければならないのですが、これらの一部は明らかにビルドキャッシュ管理の問題をかかえており、Gradleなりプラグインなりのバグでしかありません。ただこの原因を特定するのは難易度が高く生産性が低い(追及コストに見合わない)のが問題ですね。

MPPのモジュールが想定外のビルドエラーを起こすようになったら、だいたい次のいずれかを試しています(Android Studioがまともに動作しなくなった時とほぼ同じ)。

  • ./gradlew clean build (IDEAなら"Rebuild Project")
  • rm -rf (projectroot)/.idea
  • IDEAを終了してkillall -9 java

クリーンな状態でビルドできなければプロジェクトの構成を単純化して(kspなどを使う場合は対象モジュール+kspのモジュールのみにする、など)、プロジェクトの参照はmaven-publishプラグインpublishToMavenLocal~/.m2/repository/以下にパッケージを発行して、利用側のプロジェクトのbuild.gradle(.kts)mavenLocal()repositories { ... }に追加して対処します。

原因によっては、特定のプラットフォームを対象外としなければならなくなるかもしれません。今回のkotracktiveの場合は、kspを使っているとKotlin Nativeでのビルドに失敗するという問題があって、nativeビルドは無効にしています(後述の理由で割とダメージがでかい…)。JS Legacyは通るけどJS IRは通らない、みたいなのも典型的なトラブルです。

それから、この次に説明するaugene-ngリポジトリがそうなのですが、1つのリポジトリで分割したプロジェクトをGitHub Actionsなどでビルドする場合にも、このpublishToMavenLocalでいったんビルドサーバ上のローカルmavenリポジトリから参照解決できるように、ビルドスクリプトを仕込む必要があります(こんな感じです)。

…とまあ余談(?)はおいといて、System.Reflection依存のコードはこんな感じで静的コード生成で対処しました。これがreified genericsにがっつり依存してたら死んでた…(実はちょいちょい依存していたのですがロジックを書き換えて対処しました)。

Kotlin MPPで実行時型情報に基づくリフレクションを実装するのは難しいのか(!?)ということも考えたのですが、たぶんボトルネックはJS LegacyとNativeにあって(JS IRは何となく実現できそうな気がする)、NativeはIRがあるという意味では多分難易度が高くないけどバイナリ互換性がしんどそう…みたいなことを考えて深く追及しないことにしました。(そこまでいったらJetBrainsで給料払ってもらって実装するレベル…)

augene-ng: MML + MIDI + Tracktion Engine XML manipulation tool

↑のkotracktiveですっかり横道に逸れてしまった感がありますが、本来の目的はMMLからtracktioneditファイルを生成するaugeneの移植でした。kotracktiveも含めて、2週間くらい前から始めていたようです。

github.com

.NET版は実のところ4つのパートで成り立っていました。

  • ntracktive - tracktioneditのオブジェクトモデル
  • midi2tracktionedit - SMFからtracktioneditのモデルに変換するコンバーター
  • augene - midi2tracktioneditに、さらにJUCE AudioPluginHostのfiltergraphからの変換もサポートした楽曲データ生成。GUIxwt
  • augene-player - tracktion_engineを使った音楽プレイヤー

midi2tracktioneditはktmidiとkotracktiveの組み合わせなので単純でしたが、augeneアプリはxwtだったので移植性はなく、またローカルファイルに著しく依存していたり、AudioPluginHostやaugene-playerをProcess.Start()で呼び出したりと、Kotlin MPPでやるのは無理ゲー感が強かったので、まずJVM前提でCompose for Desktopに移植しました。kmmkのところでも書きましたが、Compose for Desktopの機能自体が割と未完成でしんどいところがありますが、最低限ファイルをロードして加工して保存、コンパイル、プレイヤーの起動、くらいの最低限の機能だけは動いている状態です(実用的とはとてもいえない)。

ちなみにAudioPluginHostのfiltergraphはXLinqで読み書き、augeneのプロジェクトファイル自体はXmlSerializerで読み書き…という、拷問かな?という感じの.NET API依存ぶりで無駄にしんどかったのですが、XLinqは自前で実装し、XmlSerializer依存はXmlReader/XmlWriterを使って単純に書き換えました。このXLinq実装も今はmissing-dotに移動しています。

その後、augeneの曲データモデルはfiltergraphのオブジェクトモデルと合わせてGUIから切り離して、もしKotlin Nativeで使えるようになれば(上記kspの問題でまだできないわけですが)、tracktion_engineを使ったaugene-playerに統合してin place editorにも使えるんじゃないかと考えました。1年前にも書きましたが、tracktioneditを毎回プレイヤーでロードする仕組みにしていると、オーディオプラグインの全ロードが頻繁に発生して、とてもじゃないけど編曲できたものではありません。プレイヤー上で差分更新する機能は実用化の上で避けては通れないところです。

Kotlin MPPに移行するにあたって問題になったのはだいたいこんな感じです:

  • System.IO.Fileまわり - okioを使って実装。ファイルの読み書きとパス加工が主な要求事項です。
  • IsolatedStorage - ~/.local 以下にディレクトリを掘る方向で自前実装して対処しました。
  • FileSystemWatcher - APIだけでっち上げて現状何もしていません(UIに変更を反映できなくて不便…)

一方でGUI側に残した機能もいくつかあります。Process.Start()は、Androidなどでどう実現したらいいのかわからないので、エディタに移植性は無いと判断しました(Android上ではファイルの読み書きも使い勝手が全然違うので、実質的にAndroidでの使い道は無いです)。

ktmidiとmugene-consoleのネイティブビルド

mugene-consoleはmugeneコンパイラコマンドライン上で利用する唯一の方法でしたが、これはJVMプロジェクトだったので、java -jar ...で起動するしかありませんでした。これは元々MPPのNativeビルドでコンソールツールをビルドする方法を知らなかったということが大きいのですが、build.gradle.ktsの記述方法をいろいろ調べた結果、linuxX64() { ... }などにbinaries.executable()と記述しておけば、拡張子が.kexeとなるnative executableをビルドできることがわかったので、これでビルドすることにしました(Windowsでは.exeとなるようですが未確認)。

これで、Kotlin/JSに基づくvscodium extensionだけでなくコンソール上でもKotlin/Nativeに基づくmugene-ngを実行できるようになったのですが、実際には実行可能ファイルと同じディレクトリに格納しているデフォルトマクロMMLを解決する必要があり、そのためには実行可能ファイルの位置を特定できなければならず、これが意外とネイティブコードではしんどい(!?)ということがわかったのがある意味面白かったです。どれくらいしんどいかというと、全プラットフォームで別々の実装が必要になってそれだけを実装するC++ライブラリが割と人気があるくらいです。

さいわいこの辺は何とかなって、現在はmugene-consoleはコンソールツールとして問題なく使えるようになっています。

また、これと関連して、ktmidiもC/C++などから参照して利用できるようにしたいと思って、build.gradle(.kts)で記述する方法を探して、binaries.staticLib() binaries.sharedLib()などを追加して、libktmidi.solibktmidi_api.hといったファイルが生成できるようになりました。Cヘッダファイルの内容など、なかなか趣があります。

その他

8月にもandroid-audio-plugin-frameworkのMIDIサポートを少しいじっていた時期があるのですが、これは主にkmmkからMIDI 1.0のメッセージをMidiDeviceService実装に送り込んだ時に、MIDI-CIのSet New Protocolが送られない限りはMIDI1のままでプラグインに送られるように調整したくらいです。やはりMIDI2が前提だと数多のJUCEプラグインを全部調整してビルドし直さなければならなくなるので、MIDI1のままで済むと楽になるのでした。

AAPは最近もAndroid DAWアプリの有名どころの人に「いつ使えるようになるんだ?」とか訊かれて「とりあえずMIDIバイスとして使えるようにしたい…」とか濁しているところですが、しばらくは↑のプロジェクトがメインになりそうなのでまだまだ先なんだろうな、という感じです。最近また他所のプロジェクトの仕事は手伝わなくなっているので、次のM3に向けて何かしらやっていこうと思います。MMLでまた打ち込みできる環境を何とかしたい…

*1:まだstateを使っている程度でほとんどDecomposeらしさの活用はしていませんが

*2:すでにcompose-jbリポジトリにissueがあって放置されている状態です

*3:特にAndroid Phoneではキーボードを使うとは考えにくく、タップがメインになるでしょう

*4:tracktion_engineのJUCEコードにも同様の機能があるはずなのですが、変拍子だらけの楽曲をまともにインポートできない問題があるので、自前で変換しています

*5:Maven Centralはめんどくさいのでやってません

*6:これは多分技術的には正確な記述ではなくて、たぶん型名はそもそもコンパイル時にしか取得できていない