IAC ドライバで見た NRPN の早期変換が、IAC だけの問題なのか、Core MIDI の変換そのものの動きなのかを確かめるため、仮想ポートだけで MIDI 1.0 と 2.0 の変換を 164 通り試しました。
結果は IAC 固有ではありませんでした。Core MIDI は、MIDI 2.0 から 1.0 への変換(下げる変換)はほぼ仕様どおりに行いますが、MIDI 1.0 から 2.0 への変換(上げる変換)は、仕様の Default Translation と多くの点で違います。また、変換するかどうかはメッセージの中身ではなく、送るときに宣言したプロトコルで決まります。MIDIThruConnection には、SysEx で MIDIServer が落ちる条件もありました。こちらは送る側の工夫では避けられませんでした。
検証の方法
最初の 164 通りの検証では、検証用のプログラムが、自分で作った非公開の仮想 Source と仮想 Destination だけを使って送受信しました。物理的な機器、IAC、ほかのアプリへは送っていません。後日の SysEx と MIDIThruConnection の追加検証には、MIDIFabric の公開の仮想ポートを使った試験も含みます。変換の内部状態(RPN の選択、Bank)を持ち越さないよう、試験ごとに Endpoint とポートを作り直しました。
| 経路 | 送り手 | 受け手 |
|---|---|---|
| send | MIDISendEventList(宣言 1.0 / 2.0)、MIDISend(バイト列) | 仮想 Destination(1.0 / 2.0 / packet list) |
| receive | 仮想 Source へ MIDIReceivedEventList(宣言 1.0 / 2.0)、MIDIReceived(バイト列) | 入力ポート(1.0 / 2.0 / packet list) |
| thru | 仮想 Source から MIDIThruConnection(変換の設定なし) | 仮想 Destination(1.0 / 2.0 / packet list) |
メッセージは 164 通りです(MIDI 1.0 Channel Voice の MT2 が 64、MIDI 2.0 Channel Voice の MT4 が 64、System の MT1 が 11、SysEx7 の MT3 が 8、そのほかの Message Type が 17)。結果は 5,031 行になり、期待値は MIDI 2.0 の仕様(M2-104 の Appendix D と M2-115 の min-center-max scaling)どおりに計算する参照の実装と比べました。2 回実行し、両方に共通する試験の判定の内訳は一致しました(2 回目は SysEx7 の 4 通りを追加しました)。
変換するかは、宣言したプロトコルで決まる
MIDIEventList は、中に入れた UMP とは別に、プロトコル(1.0 / 2.0)を宣言して送ります。仮想ポートでは、変換するかどうかがこの宣言だけで決まり、中身の Message Type は見ていませんでした。
| 送ったもの | 1.0 の受け手 | 2.0 の受け手 | packet list の受け手 |
|---|---|---|---|
| MT2 を宣言 1.0 | そのまま | MT4 へ変換(Note On ベロシティ 0 は例外、後述) | バイト列 |
| MT2 を宣言 2.0 | そのまま | MT2 のまま届く | バイト列 |
| MT4 を宣言 2.0 | MT2 へ変換 | そのまま | バイト列へ変換 |
| MT4 を宣言 1.0 | MT4 のまま届く | そのまま | バイト列へ変換 |
宣言と中身が食い違うと、受け手のプロトコルと合わない UMP が、そのまま届きます。packet list の受け手だけは、中身を見てバイト列にします。以前、宣言 1.0 の MT4 を USB MIDI 1.0 の機器へ送ったときは変換されたので、USB の機器への経路では、ドライバの側が中身を見て変換しているようです。
仮想 Source を作るときのプロトコル(MIDISourceCreateWithProtocol)は、結果に影響しませんでした。
MIDI 2.0 から 1.0 へ(下げる変換)
ほぼ仕様どおりでした。Note、Pressure、CC、Pitch Bend は上位のビットを取り、Note On のベロシティ 0x0000〜0x01FF は 1 になります。ノートの属性、Relative の Controller、Per-Note の系列は送られません。Program Change は、Bank が有効なら CC 0、CC 32 と Program Change に分かれます。
仕様と違ったのは 2 点です。
- RPN と NRPN の値の下位が 0 なら、CC 38 を送らない。 仕様の図は常に 4 つのメッセージです。MIDI 1.0 の受け手は、上位を受け取った時点で下位を 0 とみなすので、解釈は同じになります。
- RPN の選択を覚えて、CC 101 と CC 100 を省く。 同じ Destination の同じ Channel へ MT4 の RPN を続けて送ると、2 回目以降は、選択が直前と同じなら CC 101 / 100 を送りません。
2 点目には実害があります。MT4 で RPN 0/0 を送り、次に MT2 の CC 101 = 0、CC 100 = 1 で選択を変え、もう一度 MT4 で RPN 0/0 を送ると、Core MIDI は 2 回目の CC 101 / 100 を省き、CC 6 と CC 38 だけを送りました。受け手は直前に選ばれた RPN 0/1(Fine Tuning)へ値を書きます。Core MIDI が覚えている選択は、MT2 の選択の CC では更新されないためです。間の MT2 を別の Core MIDI client から送っても同じでした(試験では、同じプロセスの中に 2 つの client を作りました)。MIDI 2.0 の機器や IAC からの入力と、MIDI 1.0 の機器からの入力を、同じ MIDI 1.0 の機器へ流す経路で起こりえます。
MIDI 1.0 から 2.0 へ(上げる変換)
Note、Pressure、CC、Pitch Bend の値は、仕様どおり min-center-max で広げられます。ただ、仕様の Default Translation(M2-104 の D.3)とは、次の点が違いました。
| メッセージ | Core MIDI | 仕様 |
|---|---|---|
| Note On ベロシティ 0 | MT2 のまま 2.0 の受け手へ届く | Note Off、ベロシティ 0x8000 |
| CC 0 / 32(Bank Select)だけ | MT4 の CC として送る | 単独では変換しない |
| CC 0 / 32 + Program Change | CC 0、CC 32 を MT4 の CC で送り、さらに Bank 付きの Program Change を送る(Bank が 2 回伝わる) | Program Change 1 つ |
| CC 101 / 100 / 99 / 98(RPN、NRPN の選択) | MT4 の CC として送る | 変換しない |
| RPN、NRPN の値(CC 6、38) | CC 6 の時点で上位だけの値を 1 回、CC 38 で完全な値をもう 1 回送る | この CC 6 → 38 の並びでは、CC 38 を受けたときに 1 回 |
| RPN Null(101 = 127、100 = 127)のあとの CC 6 / 38 | RPN 127/127 の値として 2 回送る | Null は変換しない |
仕様では、RPN / NRPN を送る契機は、CC 38 を受けたときのほかに、次の CC 6 を受けたとき、次の CC 98〜101 を受けたときもあります。CC 38 を送らない機器のために、CC 6 の後に短い timeout を置く選択肢も書かれています。
CC 6 の時点で不完全な値を送る動きは、以前 IAC で見たものと同じで、IAC 固有ではなく Core MIDI の変換そのものでした(「NRPN が最初に違う値で届くのはなぜ?」)。同じ Destination の同じ Channel へ送った試験では、変換の状態(RPN の選択)が、送り手の client をまたいで共有されました。ある client の CC 101 / 100 と、別の client の CC 6 / 38 から、RPN 0/0 の値が作られました。
MIDIThruConnection の問題
MIDIThruConnection は、Source から Destination へ MIDI を転送する Core MIDI の仕組みです。ここには 3 つの問題がありました。
- SysEx で MIDIServer が落ちる。 詳しくは次の節に書きます。
- CC 98〜101 を通さない。 RPN と NRPN の選択の CC が消え、CC 6 と CC 38 だけが届きます。
- MIDI 1.0 を経由する。 MIDI 2.0 の Source から MIDI 2.0 の Destination へ通しても、値が MIDI 1.0 の分解能に落ちます。ベロシティ
0x7FFFは0x7E00になり、ノートの属性、Relative、Per-Note の系列は消えます。
Thru の入口へ SysEx を流すと MIDIServer が落ちる
SysEx7 の 3 UMP 以上を 1 つの packet に入れて Thru の入口へ流すと、MIDIServer が EXC_BAD_ACCESS(SIGSEGV または SIGBUS)で落ちました。3 UMP は F0 と F7 を除いて 13 バイト以上です。macOS 27.0.1 と 27.2 で 13 回試し、すべて落ちています。試した 3 通りのプロトコルの組み合わせ(1.0 / 1.0、宣言 2.0 で 2.0 / 2.0、1.0 から packet list の受け手)のどれでも、MIDIReceived のバイト列でも、timestamp に送る時点の時刻を入れても同じでした。MIDIReceived では 10 バイトで落ちなかったので、境界は 13 バイトと推定していますが、13 バイトちょうどは試していません。2 UMP では落ちませんが、先頭の UMP だけが転送され、残りは捨てられます。
MIDIServer が落ちると、Mac のすべてのアプリの MIDI が止まります(「Mac の音楽アプリで、MIDI が突然止まるのはなぜ?」)。続けて落ちると、macOS(launchd)は MIDIServer の再起動を遅らせます。今回は、数分から十数分のあいだ起動せず、そのあいだはどのアプリも MIDI を使えませんでした。
最初は、UMP を 1 つずつ別の event list で送れば避けられると考えていました。4 UMP では届いたからです。しかし、UMP の数を増やすと落ちました。次の表は macOS 27.2 で、UMP ごとに別の event list で送った結果です。Thru を通さない列は、入力ポートで受けて、届いた packet の区切りを見たものです。
| 送り方 | Thru を通さないとき | Thru を通したとき |
|---|---|---|
| 間隔なし、すべて同じ timestamp | 1 つの packet にまとめて届く | 4 UMP で落ちる |
| 間隔なし、送るたびにその時点の時刻 | 4〜6 UMP は 1 つずつ届く。8 UMP 以上は、ときどき 2〜3 個がまとめて届く | 4 UMP は届く。10 UMP は落ちる(MIDIFabric の経路、1 回) |
| 1 ms 間隔、timestamp 0 | 64 UMP まで、1 つずつ別の packet で届く | 4 UMP は届く。32 UMP は落ちる(1 回) |
最後の行のとおり、Thru を通さない試験では 1 UMP ずつ届いた送り方でも、Thru を通す試験では落ちました。Thru の中で packet がどうまとめられているかは、確認できていません。送る側の工夫で避ける方法は、見つかっていません。Apple には報告済みです。
問題がなかったこと
- 今回の試験では、展開した並びへの割り込みはなかった。 2 つの送り手(別の client、または別の仮想 Source)が、MT4 の RPN(または Bank + Program Change)と MT2 の Note を 400 回ずつ、同時に同じ MIDI 1.0 の受け手へ送っても、展開した CC の並びの途中に、ほかのメッセージが入ることはありませんでした。
- タイムスタンプは保たれる。 200 ms 先のタイムスタンプで送ると、変換して展開したメッセージも、すべてそのタイムスタンプで届きました。
- 400 個の連続送信で欠落はなかった。 間隔なしで 400 個送ると、全部届きました。ただし Core MIDI は、連続した送信を 1 つの
MIDIEventPacketにまとめることがあり、wordCountが 64 を超えました。受け手は、wordsを 64 要素の配列としてではなく、ポインタで読む必要があります。
アプリを作るときの注意
- 宣言するプロトコルを、中身と合わせる。 MT2 は宣言 1.0、MT4 は宣言 2.0 で送ります。食い違うと、受け手は自分のプロトコルと違う UMP を受け取ります。
- 正確な MIDI 2.0 が必要なら、送り手が自分で変換する。 Core MIDI の上げる変換は仕様と違うので、MIDI 2.0 の受け手へ正確な値を渡したいときは、送り手が仕様どおりに MT4 へ変換し、宣言 2.0 で送ります。MIDI 2.0 で受け取るアプリは、MT2 や、単独の CC 0 / 32 / 6 / 38 / 98〜101 が MT4 の CC として届くことを前提にします。
- 同じ MIDI 1.0 の受け手へ、MT4 の RPN と MT2 の選択の CC を混ぜない。 送り手が MIDI 1.0 の CC へ自分で展開してから MT2 で送れば、Core MIDI は CC 101 / 100 を省きません。
- SysEx や RPN を扱う経路に
MIDIThruConnectionを使わない。 3 UMP 以上の SysEx は、送り方を工夫しても防げませんでした。SysEx を転送するなら、アプリが受け取って自分で送り直します。
検証の範囲
ここに書いたのは、macOS 27.0.1 の仮想ポートでの結果です。SysEx と Thru は macOS 27.2 でも確かめ、同じでした。USB MIDI 2.0 の機器、IAC、ネットワーク MIDI の経路、ほかの macOS の版や iPadOS、MIDIThruConnection の変換の設定(チャンネルのマップなど)を使った場合は、この検証には含まれていません。Core MIDI の内部は見えないので、仕様との違いが意図したものか不具合かは、分かりません。
MIDIFabric について
MIDIFabricは MIDIThruConnection を使いません(仮想デバイスの Thru も MIDIFabric の中で転送しています)。ただし、ほかのアプリが MIDIFabric の仮想デバイスを入口にした MIDIThruConnection を作っていると、MIDIFabric が送る 3 UMP 以上の SysEx で MIDIServer が落ちることがあります。MIDIFabric の仮想デバイスを入口にした Thru で試すと、15 バイトと 20 バイトの SysEx は届き、60 バイトで落ちました。MIDIFabric は、限定的な緩和として、SysEx の UMP ごとに timestamp を変えて送るようにしました。今回の 3〜4 UMP の試験では届きましたが、回避を保証するものではありません。MIDIFabric が IAC バスへ送り、その IAC バスを入口にした Thru がある場合も同じことが起こりうると考えていますが、試していません。
一方、MIDI 1.0 と 2.0 の変換は今は Core MIDI に任せているので、上で書いた変換の違いは MIDIFabric の経路でも起きます。どこで起きるかは、機器と仮想ポートのプロトコル、Routing のつなぎ方で変わります。
- MIDIFabric が送る MIDI 1.0 は、受け取るアプリが MIDI 2.0 の入力ポートで受けると、上げる変換を受けます。IAC へ送った場合も同じです。
- MIDI 2.0 で作った仮想デバイスへ他のアプリが MIDI 1.0 で送った MIDI や、他のアプリが MIDI 1.0 で IAC へ送った MIDI は、Core MIDI の上げる変換を経て MIDIFabric に届きます。変換の結果には、上で述べた例外も含まれます。
- MIDI 2.0 の Source と MIDI 1.0 の Source を同じ MIDI 1.0 の機器の同じ Channel へ Route すると、RPN の選択が省かれて、別の RPN が書き換わることがあります。
この検証の結果は、MIDIFabric が自分で変換するかどうかを決める材料にしています。ここに書いた動きは Core MIDI のもので、MIDIFabric を使わない経路でも起きます。