正式な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に持ってきたサンプルコードも書きたかったのですが、ちと別の終わってない草稿もかかえているので、また別の機会に…!