To find out whether the early NRPN conversion we had seen through the IAC Driver was specific to IAC or part of Core MIDI’s conversion itself, we tested conversion between MIDI 1.0 and 2.0 in 164 cases using only virtual ports.
It was not specific to IAC. Core MIDI follows the specification fairly closely when converting MIDI 2.0 down to 1.0, but its MIDI 1.0 to 2.0 conversion differs from the specification’s Default Translation in many places. Whether it converts at all depends on the protocol declared when sending, not on the message content. And MIDIThruConnection has a condition under which SysEx crashes MIDIServer, which we could not avoid on the sending side.
How we tested
For the first 164 cases, a test program sent and received using only private virtual Sources and Destinations it created itself. Nothing was sent to physical devices, IAC or other apps. The later tests of SysEx and MIDIThruConnection also include tests with a public virtual port created by MIDIFabric. Endpoints and ports were recreated for every test so that no internal conversion state (RPN selection, bank) carried over.
| Path | Sender | Receiver |
|---|---|---|
| send | MIDISendEventList (declared 1.0 / 2.0), MIDISend (bytes) | virtual Destination (1.0 / 2.0 / packet list) |
| receive | MIDIReceivedEventList to a virtual Source (declared 1.0 / 2.0), MIDIReceived (bytes) | input port (1.0 / 2.0 / packet list) |
| thru | from a virtual Source through MIDIThruConnection (no transform settings) | virtual Destination (1.0 / 2.0 / packet list) |
There were 164 messages: 64 MIDI 1.0 Channel Voice (MT2), 64 MIDI 2.0 Channel Voice (MT4), 11 System (MT1), 8 SysEx7 (MT3) and 17 of other Message Types. The results came to 5,031 rows, compared against a reference implementation that computes the expected values from the MIDI 2.0 specification (M2-104 Appendix D and the min-center-max scaling in M2-115). We ran it twice, and the verdict breakdown matched for the cases common to both runs (the second run added 4 SysEx7 cases).
Conversion depends on the declared protocol
A MIDIEventList carries a declared protocol (1.0 or 2.0), separate from the UMP inside it. With virtual ports, whether Core MIDI converts depends only on that declaration; it does not look at the Message Type of the content.
| Sent | 1.0 receiver | 2.0 receiver | packet list receiver |
|---|---|---|---|
| MT2 declared 1.0 | unchanged | converted to MT4 (except Note On velocity 0, see below) | bytes |
| MT2 declared 2.0 | unchanged | arrives as MT2 | bytes |
| MT4 declared 2.0 | converted to MT2 | unchanged | converted to bytes |
| MT4 declared 1.0 | arrives as MT4 | unchanged | converted to bytes |
When the declaration and the content disagree, a receiver gets UMP that does not match its own protocol. Only packet list receivers look at the content and turn it into bytes. Earlier, MT4 declared 1.0 was converted when sent to a USB MIDI 1.0 device, so on the path to USB devices the driver side appears to look at the content.
The protocol a virtual Source is created with (MIDISourceCreateWithProtocol) made no difference.
MIDI 2.0 down to 1.0
This mostly follows the specification. Note, pressure, CC and Pitch Bend keep the upper bits, and Note On velocities 0x0000 to 0x01FF become 1. Note attributes, Relative controllers and Per-Note messages are not sent. A Program Change with a valid bank becomes CC 0, CC 32 and Program Change.
It differed from the specification in two ways:
- No CC 38 when the low part of an RPN or NRPN value is 0. The specification’s figure always shows four messages. A MIDI 1.0 receiver treats the low part as 0 when the high part arrives, so the result is interpreted the same way.
- It remembers the RPN selection and omits CC 101 and CC 100. When MT4 RPNs are sent to the same channel of the same Destination in a row, from the second one on it leaves out CC 101 / 100 if the selection is the same as before.
The second one does real harm. We sent RPN 0/0 as MT4, then changed the selection with MT2 CC 101 = 0 and CC 100 = 1, then sent RPN 0/0 as MT4 again. Core MIDI left out CC 101 / 100 the second time and sent only CC 6 and CC 38, so the receiver writes the value to the RPN selected last, RPN 0/1 (Fine Tuning). The selection Core MIDI remembers is not updated by MT2 selection CCs. It was the same when the MT2 came from another Core MIDI client (in our tests, a second client created in the same process). This can happen on a path that sends input from a MIDI 2.0 device or IAC, together with input from a MIDI 1.0 device, to the same MIDI 1.0 device.
MIDI 1.0 up to 2.0
Values of Note, pressure, CC and Pitch Bend are scaled with min-center-max, as specified. But Core MIDI differed from the specification’s Default Translation (M2-104 D.3) in these ways:
| Message | Core MIDI | Specification |
|---|---|---|
| Note On velocity 0 | reaches 2.0 receivers as MT2 | Note Off, velocity 0x8000 |
| CC 0 / 32 (Bank Select) alone | sent as MT4 CCs | not converted on their own |
| CC 0 / 32 + Program Change | CC 0 and CC 32 as MT4 CCs, plus a Program Change with the bank (the bank is conveyed twice) | one Program Change |
| CC 101 / 100 / 99 / 98 (RPN, NRPN selection) | sent as MT4 CCs | not converted |
| RPN, NRPN value (CC 6, 38) | once at CC 6 with only the high part, again at CC 38 with the full value | for this CC 6 → 38 sequence, once when CC 38 arrives |
| CC 6 / 38 after RPN Null (101 = 127, 100 = 127) | sent twice as a value for RPN 127/127 | Null is not converted |
In the specification, an RPN / NRPN is also sent when a subsequent CC 6 arrives or when a subsequent CC 98 to 101 arrives, not only when CC 38 arrives. It also mentions that a translator may add a short timeout after CC 6 for devices that do not send CC 38.
Sending an incomplete value at CC 6 is the same behavior we had seen through IAC, and it turned out to be Core MIDI’s conversion itself, not something specific to IAC (see Why does an NRPN arrive with the wrong value first?). In our tests sending to the same channel of the same Destination, the conversion state (RPN selection) was shared across sending clients: an RPN 0/0 value was built from one client’s CC 101 / 100 and another client’s CC 6 / 38.
Problems with MIDIThruConnection
MIDIThruConnection is Core MIDI’s mechanism for forwarding MIDI from a Source to a Destination. It had three problems:
- SysEx can crash MIDIServer. See the next section.
- CC 98 to 101 do not pass. The RPN and NRPN selection CCs disappear and only CC 6 and CC 38 arrive.
- It goes through MIDI 1.0. Even from a MIDI 2.0 Source to a MIDI 2.0 Destination, values drop to MIDI 1.0 resolution. Velocity
0x7FFFbecomes0x7E00, and note attributes, Relative controllers and Per-Note messages disappear.
SysEx into a Thru can crash MIDIServer
Putting a SysEx7 of three or more UMPs in one packet and feeding it into a Thru crashed MIDIServer with EXC_BAD_ACCESS (SIGSEGV or SIGBUS). Three UMPs means 13 bytes or more, excluding F0 and F7. We tried it 13 times on macOS 27.0.1 and 27.2, and every run crashed. It made no difference which of the three protocol combinations we used (1.0 / 1.0, declared 2.0 with 2.0 / 2.0, and 1.0 to a packet list receiver), whether we sent bytes with MIDIReceived, or whether the timestamp was the time of sending. With MIDIReceived, 10 bytes did not crash, so we estimate the boundary at 13 bytes, but we did not test exactly 13 bytes. With two UMPs it did not crash, but only the first UMP was forwarded and the rest was dropped.
When MIDIServer crashes, MIDI stops for every app on the Mac (see Why does MIDI suddenly stop working in a Mac music app?). After several crashes in a row, macOS (launchd) delays restarting MIDIServer. In our case it did not start for several minutes to more than ten minutes, and during that time no app could use MIDI.
At first we thought sending each UMP in its own event list would avoid the crash, because four UMPs arrived that way. With more UMPs, it crashed. The table shows results on macOS 27.2, with each UMP sent in its own event list. The column without a Thru shows how the packets arrived at an input port.
| How the event lists were sent | Without a Thru | Through a Thru |
|---|---|---|
| No delay, all with the same timestamp | arrived merged into one packet | crashed with 4 UMPs |
| No delay, each with the time of sending | 4 to 6 UMPs arrived one at a time; with 8 UMPs or more, 2 or 3 sometimes arrived merged | 4 UMPs arrived; 10 UMPs crashed (MIDIFabric’s path, one run) |
| 1 ms apart, timestamp 0 | up to 64 UMPs, each arrived in its own packet | 4 UMPs arrived; 32 UMPs crashed (one run) |
As the last row shows, a sending pattern that arrived one UMP at a time without a Thru still crashed MIDIServer through a Thru. We could not see how packets are grouped inside the Thru. We have not found a way to avoid it on the sending side. We have reported it to Apple.
What worked fine
- No interleaving within expanded sequences in our tests. With two senders (separate clients, or separate virtual Sources) sending 400 MT4 RPNs (or Bank + Program Change) and 400 MT2 Notes at the same time to the same MIDI 1.0 receiver, no other message ever appeared in the middle of an expanded CC sequence.
- Timestamps are kept. Sent with a timestamp 200 ms ahead, every message, including converted and expanded ones, arrived with that timestamp.
- No loss in 400 back-to-back sends. All 400 messages sent without gaps arrived. However, Core MIDI can merge consecutive sends into one
MIDIEventPacket, andwordCountexceeded 64. Receivers need to readwordsthrough a pointer, not as a 64-element array.
What to watch for when building an app
- Declare the protocol that matches the content. Send MT2 declared 1.0 and MT4 declared 2.0. If they disagree, receivers get UMP that does not match their protocol.
- If you need exact MIDI 2.0, convert it yourself. Core MIDI’s up conversion differs from the specification, so to give a MIDI 2.0 receiver exact values, convert to MT4 yourself as specified and send it declared 2.0. Apps that receive MIDI 2.0 should expect MT2, and lone CC 0 / 32 / 6 / 38 / 98–101 arriving as MT4 CCs.
- Do not mix MT4 RPNs and MT2 selection CCs to the same MIDI 1.0 receiver. If the sender expands to MIDI 1.0 CCs itself and sends MT2, Core MIDI does not omit CC 101 / 100.
- Do not use
MIDIThruConnectionon paths that carry SysEx or RPNs. We could not prevent the crash with SysEx of three or more UMPs by changing how it is sent. To forward SysEx, receive it in your app and send it again yourself.
Scope of these tests
These are results for virtual ports on macOS 27.0.1. We also tested SysEx and Thru on macOS 27.2, with the same results. USB MIDI 2.0 devices, IAC, network MIDI paths, other macOS versions or iPadOS, and MIDIThruConnection with transform settings (such as channel maps) were not part of these tests. We cannot see inside Core MIDI, so we cannot tell whether the differences from the specification are intended or bugs.
About MIDIFabric
MIDIFabric does not use MIDIThruConnection (its virtual devices’ Thru is also forwarded inside MIDIFabric). But if another app creates a MIDIThruConnection whose input is one of MIDIFabric’s virtual devices, SysEx of three or more UMPs sent by MIDIFabric can crash MIDIServer. Through a Thru from a MIDIFabric virtual device, 15-byte and 20-byte SysEx messages arrived, and a 60-byte one crashed MIDIServer. As a limited mitigation, MIDIFabric now sends each UMP of a SysEx with a different timestamp. In our tests with three and four UMPs the messages arrived, but this does not guarantee that the crash is avoided. We expect the same can happen when MIDIFabric sends to an IAC bus that is the input of a Thru, but we have not tested it.
For conversion between MIDI 1.0 and 2.0, however, it currently relies on Core MIDI, so the conversion differences described above also happen on MIDIFabric’s paths. Where they happen depends on the protocol of your devices and virtual ports, and on how you connect them in Routing.
- MIDI 1.0 sent by MIDIFabric goes through the up conversion when the receiving app reads it with a MIDI 2.0 input port. The same applies when it is sent to IAC.
- MIDI 1.0 that another app sends to a virtual device created as MIDI 2.0, or to IAC, passes through Core MIDI’s up conversion before reaching MIDIFabric, with the exceptions described above.
- If you route a MIDI 2.0 Source and a MIDI 1.0 Source to the same channel of the same MIDI 1.0 device, the RPN selection can be omitted and a different RPN can be overwritten.
These results are what we use to decide whether MIDIFabric should do the conversion itself. The behavior described here is in Core MIDI and also happens on paths that do not use MIDIFabric.