The App I Set Out to Build
Three screens. That was the plan.
- Sliders page — six sliders mapped to CC1 (modulation), CC7 (volume), CC10 (pan), and others
- Program Change page — bank select plus PC 0–127
- XY Pad — two 2-axis pads, each axis assignable to any CC
All of that is pure MIDI 1.0. SwiftUI for the interface, CoreMIDI for sending bytes, an afternoon to write. Nothing complicated. This was supposed to be a utility app, not a research project.
Then I Plugged In a KORG KeyStage
The KeyStage supports MIDI 2.0 Property Exchange. When you connect it to an app, it sends out PE queries over MIDI-CI — asking the app for its sound name list, its parameter structure, anything the app is willing to share. If the app responds correctly, the KeyStage LCD shows those sound names. The hardware knobs display parameter names. It’s exactly the kind of integration that makes MIDI 2.0 feel genuinely useful rather than theoretical.
I thought: “That would be really useful. How hard is it to respond to those queries?”
Reader: it was not easy.
Building the MIDI-CI Stack by Hand
Property Exchange doesn’t stand alone. Before two devices can exchange PE data, they have to complete a MIDI-CI (Capability Inquiry) handshake. It’s a multi-stage discovery and negotiation protocol that looks roughly like this:
- Discovery — broadcast a CI Discovery Inquiry message, collect responses, extract each device’s MUID (a unique 28-bit device identifier)
- PE Capability Inquiry — confirm that the remote device actually supports Property Exchange
- ResourceList request — ask the device what resources (data categories) it exposes
- Individual resource GETs — fetch DeviceInfo, ProgramList, parameter mappings, and anything else on the list
Each step involves constructing SysEx messages to specific formats, handling chunked responses (PE data can be split across multiple messages), managing per-request timeouts, and tracking request IDs (7-bit values, 0–127) across concurrent transactions.
I wrote all of it by hand. Seven files, roughly 2,800 lines of Swift.
When it was done, it worked. KORG Module Pro returned its sound name list. The KeyStage LCD displayed the names. Knobs showed parameter labels. For about a week, I felt pretty good about this.
Where It Started Breaking
Bug 1: SysEx Fragments Arriving Out of Order
CoreMIDI’s MIDIPacketList can contain multiple packets in a single callback. My first implementation spawned a new Task for each individual packet. Swift’s structured concurrency doesn’t guarantee execution order across independent tasks, so SysEx fragments from chunked PE responses were sometimes being processed out of sequence.
// The broken version — a new Task per packet
for packet in packetList {
Task { await assembler.process(packet) }
}
// The fix — process the whole list in one await
await assembler.processPacketList(packetList)
The fix seems obvious in retrospect. At the time it took a while to figure out why reassembled responses were occasionally corrupt.
Bug 2: MUID Regenerated on Every Discovery
The MUID is the identifier your app uses to represent itself in MIDI-CI conversations. My first version generated a fresh random MUID every time startDiscovery() was called.
The problem: if discovery ran while a PE transaction was in progress, responses from the remote device would be addressed to the old MUID, which no longer existed. Transactions would silently fail.
Fix: generate the MUID once at initialization and keep it for the lifetime of the session.
Bug 3: iPad-Specific Data Corruption
This one I never actually solved.
On iPad (specifically iPad14,10 running iOS 18.6.2), receiving a chunked ResourceList response from KORG Module Pro caused bytes 338–342 of the second chunk to be replaced with garbage. Every single time. 100% reproduction rate. The exact same code running on iPhone worked perfectly.
I spent a long time trying to figure out whether this was a CoreMIDI BLE driver bug, a KORG Module Pro bug specific to iPad, or something about how the BLE MIDI framing worked on that hardware. I never got a clean answer. Eventually I classified it as a known limitation and added a note recommending iPhone over iPad. Not satisfying, but it let me ship.
Bug 4: Request IDs Leaking Until Exhaustion
PE request IDs are 7 bits: 128 possible values, 0–127. When a transaction completes successfully, the ID gets recycled. When a transaction fails — timeout, NAK, silent drop — my implementation wasn’t always cleaning up the transaction object, so the ID stayed reserved.
Under normal use this wasn’t a problem. After extended sessions with lots of failed or retried transactions, the pool would slowly drain until no new requests could be sent. The app would silently stop working without any obvious error.
Bug 5 (Not Really a Bug, More a Disaster): Concurrency Model
I’d built MIDICIManager with a mix of DispatchQueue for CoreMIDI callbacks and @MainActor for UI updates. In Swift 5, this worked fine. When I turned on Swift 6 strict concurrency checking, I got a wave of data race warnings — the kind that aren’t just style issues but represent real potential for undefined behavior.
Fixing this properly would require rethinking the entire concurrency architecture, not just applying Band-Aids.
The Moment I Realized This Was a Library Problem
By January 2026, I had 2,800 lines of MIDI-CI and PE code baked directly into a controller app. No tests. No documentation. Logic that was tightly coupled to the app’s internal state. Every time I fixed a bug I’d think: “this isn’t an app bug, this is a protocol implementation bug.”
On January 16th I sat down and wrote out what the actual problem was:
SimpleMIDIController contains CoreMIDI direct access, SysEx assembly, MIDI-CI Discovery, and Property Exchange transaction/chunk/timeout management all in the same codebase. It cannot be reused, tested, or maintained in this state.
That was the start of MIDI2Kit.
The bugs themselves weren’t that hard to fix individually. The problem was structural: protocol implementation does not belong inside an app. It belongs in a library with a proper API surface, with tests that can verify each layer in isolation, with the concurrency model thought through from the start rather than bolted on after the fact.
In the next post I’ll walk through what that redesign looked like — the decisions that went into MIDI2Kit’s architecture and how it handles the problems I hit with the hand-rolled approach.
Get Started with MIDI2Kit
The MIDI 2.0 library that handles the messy reality of real-world devices. Open source, MIT licensed.