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_basics
とjuce_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ビットなのかを判別して、逐次変換していく必要があります。これを実装するためにIterator
とView
というクラスが定義されています。
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()
でバッファを確保する仕組み(というか前提)をもつシンプルなクラスです。Packets
はPacket<numWords>
やView
をadd()
で追加できますが、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
をテンプレート引数としており、View
1つを引数にとる関数(の右辺値参照)と説明されています。この関数が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::AudioProcessor
でMIDIバッファを受け取る時もこのクラスをjuce::MidiBuffer
から取り出して使ったりします。Conversion::toMidi1()
関数を使うと、このMidiMessage
をUMPのView
に変換できます。ひとつ留意すべき点として、toMidi1()
には2つのオーバーロードがあります。
これらもmidi2ToMidi1DefaultTranslation()
と同じく関数テンプレートで、引数callback
はView
1つを受け取るテンプレート引数です。
Conversion
クラスとは別系統で、UMP1Converter
、UMP2Converter
といったクラスも定義されています。これらはMidiMessage
またはView
を受け取ってそれぞれ対応するView
に変換するconvert()
関数を定義しています。また、GenericUMPConverter
クラスはこの2クラスをまとめていて、同じconvert()
を提供しつつ、コンストラクタ引数でPacketProtocol
列挙値によってこれらを使い分けています。(このクラスにはコレクションの変換メソッドもあるのですが、まだコレクション変換の説明をしていないのでここでは省略します。)
これらはあくまで整数値や単一のパケットView
の変換なので(
Midi2ToMidi1DefaultTranslator
は少々事情が異なりますが)、バイトストリームやUMPストリームの変換にはまた別の変換関数が必要です。
Dispatcher
: View
のタイムスタンプ付きイテレーター
複数のパケットView
を扱うクラスとしてIterator
やPackets
を紹介しましたが、もうひとつ、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リファレンスからは型定義を取得できないのですが(!)、View
とdouble
(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)の実装で、コンストラクタ引数でPacketProtocol
とReceiver
を指定し、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)の実装で、コンストラクタ引数でPacketProtocol
とReceiver
を指定し、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に変換します。
ToBytestreamConverter
のconvert()
は、View
とtimestampをMidi1ToBytestreamTranslator
によってMidiMessage
に変換したものを、Fn&& fn
に渡して処理させます(MidiMessage
をそのままfn
に渡すオーバーロード関数もあります)
ToBytestreamDispatcher
のdispatch()
は、uint32_t
のバッファ(begin/endのペア)とtimestamp値を受け取ってMidiMessage
を処理するcallbackに逐次処理させます。その中では、View
とtimestamp値をMidiMessage
に変換するToBytestreamConverter
が活用されています。
おわり
今年はJUCEのUMPサポートAPIが正式版になったので、具体的に詳しく1つひとつ見て解説してみました。
余力があったら筆者がktmidiやらcmidi2やらでさんざん書いてきたような変換コードをJUCEに持ってきたサンプルコードも書きたかったのですが、ちと別の終わってない草稿もかかえているので、また別の機会に…!