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:これは多分技術的には正確な記述ではなくて、たぶん型名はそもそもコンパイル時にしか取得できていない