A Swift library for MIDI 2.0 / MIDI-CI / Property Exchange on Apple platforms.
- MIDI-CI Device Discovery - Automatically discover MIDI 2.0 capable devices
- Property Exchange - Get and set device properties via PE protocol
- Advanced SET Operations - Payload validation, batch SET, and pipeline workflows
- UMP SysEx7 Bidirectional Conversion - MIDI 1.0 SysEx ↔ UMP Data 64 packet conversion
- Multi-Packet SysEx7 Reassembly - Actor-based UMPSysEx7Assembler for fragmented messages
- RPN/NRPN → MIDI 1.0 CC Conversion - UMP RPN/NRPN to MIDI 1.0 Control Change approximation
- High-Level API - Simple
MIDI2Clientactor for common use cases - Responder API -
MIDI2ResponderClientfor creating MIDI-CI Responders with typed resources (v1.0.17+) - Connection Policy -
MIDI2ConnectionPolicyfor selective device filtering (v1.0.17+) - KORG Optimization - 99% faster PE operations with KORG devices (v1.0.8+)
- Adaptive Warm-Up - Automatic connection optimization with device learning
- Swift Concurrency - Built with async/await and Sendable types
- iOS 17.0+ / macOS 14.0+ / tvOS 17.0+ / watchOS 10.0+ / visionOS 1.0+
- Xcode 16.0+
- Swift 6.0+
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/hakaru/MIDI2Kit.git", from: "1.0.0")
]Or in Xcode: File → Add Package Dependencies → Enter the repository URL.
If GitHub Actions cannot run (for example due to billing limits), you can run the same consumer smoke test locally for every release tag:
git clone https://github.com/midi2kit/MIDI2Kit-SDK.git
cd MIDI2Kit-SDK
chmod +x Scripts/consumer-smoke-local.sh
Scripts/consumer-smoke-local.sh 1.0.14You can also pass a tag with v prefix and/or a custom repository URL:
Scripts/consumer-smoke-local.sh v1.0.14 https://github.com/midi2kit/MIDI2Kit-SDK.gitSuccess ends with:
OK: MIDI2Kit-SDK consumer smoke passed
import MIDI2Kit
// Create and start the client
let client = try MIDI2Client(name: "MyApp")
try await client.start()
// Listen for device discovery
Task {
for await event in await client.makeEventStream() {
switch event {
case .deviceDiscovered(let device):
print("Found: \(device.displayName)")
// Get device info if PE is supported
if device.supportsPropertyExchange {
let info = try await client.getDeviceInfo(from: device.muid)
print("Product: \(info.productName ?? "Unknown")")
}
case .deviceLost(let muid):
print("Lost: \(muid)")
default:
break
}
}
}
// Later: clean shutdown
await client.stop()Customize behavior with MIDI2ClientConfiguration:
var config = MIDI2ClientConfiguration()
// Discovery settings
config.discoveryInterval = .seconds(5)
config.deviceTimeout = .seconds(30)
// PE settings
config.peTimeout = .seconds(10)
config.maxRetries = 3
// Create client with custom config
let client = try MIDI2Client(name: "MyApp", configuration: config)Or use presets:
// KORG BLE MIDI devices (warm-up enabled, longer timeouts)
let client = try MIDI2Client(name: "MyApp", preset: .korgBLEMIDI)
// Standard MIDI 2.0 devices (default settings)
let client = try MIDI2Client(name: "MyApp", preset: .standard)Available presets:
.korgBLEMIDI- Optimized for KORG Module Pro and BLE MIDI devices.standard- Default settings for standard MIDI 2.0 devices
MIDI2Kit provides bidirectional conversion between MIDI 1.0 SysEx and UMP SysEx7 (Data 64) packets, along with multi-packet reassembly and RPN/NRPN to MIDI 1.0 CC conversion.
import MIDI2Core
// Convert MIDI 1.0 SysEx to UMP Data 64 packets
let sysex: [UInt8] = [0xF0, 0x7E, 0x7F, 0x09, 0x01, 0xF7]
let packets = UMPTranslator.fromMIDI1SysEx(sysex)
// Returns array of UMPMessages (automatically chunked into 6-byte payloads)
// Or use factory API
let packets2 = UMP.sysEx7.fromMIDI1(bytes: sysex)// Convert single UMP Data 64 packet to MIDI 1.0 SysEx
let ump: UMPMessage = // ... received UMP packet
if let sysexBytes = UMPTranslator.data64ToMIDI1SysEx(ump) {
// sysexBytes includes 0xF0 start and 0xF7 end
print("Converted SysEx: \(sysexBytes)")
}For fragmented SysEx messages spanning multiple UMP packets:
// Create assembler (actor-based, thread-safe)
let assembler = UMPSysEx7Assembler()
// Process incoming UMP packets
for packet in receivedPackets {
if let completeSysEx = await assembler.process(packet) {
// Received complete SysEx message (includes 0xF0 and 0xF7)
print("Complete SysEx: \(completeSysEx)")
}
}
// Timeout handling
if let timedOut = await assembler.popTimedOut() {
print("Partial SysEx timed out: \(timedOut)")
}Create UMP Data 64 packets with the convenient factory API:
// Single complete packet
let singlePacket = UMP.sysEx7.complete(payload: [0x7E, 0x7F, 0x09, 0x01])
// Multi-packet sequence
let payload = Array(repeating: UInt8(0x42), count: 20) // 20 bytes → 4 packets
let packets = UMP.sysEx7.fromMIDI1(bytes: [0xF0] + payload + [0xF7])
// Returns array of Data 64 UMP messages with appropriate status (start/continue/end)Convert UMP RPN/NRPN messages to MIDI 1.0 Control Change approximations:
// Convert RPN to MIDI 1.0 CC sequence
let rpnMessage: UMPMessage = // ... UMP RPN message
let ccMessages = UMPTranslator.rpnToMIDI1ControlChange(rpnMessage)
// Returns array of MIDI 1.0 CC messages: [CC#101, CC#100, CC#6, CC#38]
// Convert NRPN to MIDI 1.0 CC sequence
let nrpnMessage: UMPMessage = // ... UMP NRPN message
let ccMessages = UMPTranslator.nrpnToMIDI1ControlChange(nrpnMessage)
// Returns array of MIDI 1.0 CC messages: [CC#99, CC#98, CC#6, CC#38]Note: RPN/NRPN conversion is an approximation - UMP uses 32-bit resolution while MIDI 1.0 uses 7-bit (MSB) + 7-bit (LSB). The conversion preserves the 14-bit MSB value for compatibility.
The main entry point for MIDI 2.0 operations.
| Method | Description |
|---|---|
start() |
Start discovery and event processing |
stop() |
Stop and clean up all resources |
makeEventStream() |
Get an AsyncStream of events |
getDeviceInfo(from:) |
Get DeviceInfo from a device |
getResourceList(from:) |
Get available PE resources |
get(_:from:) |
Get a PE resource |
set(_:data:to:) |
Set a PE resource |
Represents a discovered MIDI 2.0 device with cached property access.
| Property/Method | Description |
|---|---|
muid |
Unique MIDI ID |
displayName |
Human-readable name |
supportsPropertyExchange |
PE capability |
manufacturerName |
Manufacturer (if known) |
deviceInfo |
Cached DeviceInfo (auto-fetched) |
resourceList |
Cached resource list (auto-fetched) |
getProperty<T>(_:as:) |
Type-safe property decoding |
invalidateCache() |
Force fresh fetch on next access |
Example:
let device: MIDI2Device = // from .deviceDiscovered event
// Cached access (auto-fetches on first call)
if let info = try await device.deviceInfo {
print("Product: \(info.productName ?? "Unknown")")
}
// Type-safe property access
struct CustomProperty: Codable {
let value: String
}
if let prop = try await device.getProperty("X-Custom", as: CustomProperty.self) {
print("Custom: \(prop.value)")
}Events emitted by the client.
| Event | Description |
|---|---|
.deviceDiscovered(device) |
New device found |
.deviceLost(muid) |
Device disconnected |
.deviceUpdated(device) |
Device info updated |
.notification(notification) |
PE subscription notification |
.started / .stopped |
Client lifecycle |
MIDI2Kit uses os.Logger for efficient logging:
// Disable all logs
MIDI2Logger.isEnabled = false
// Enable verbose logging
MIDI2Logger.isVerbose = trueFilter logs in Console.app:
subsystem == "com.midi2kit"
MIDI2Client provides diagnostic tools for troubleshooting:
// Get comprehensive diagnostics
let diag = await client.diagnostics
print(diag)
// Check destination resolution details
if let destDiag = await client.lastDestinationDiagnostics {
print("Tried destinations: \(destDiag.triedOrder)")
print("Resolved to: \(destDiag.resolvedDestination)")
}
// View last communication trace
if let trace = await client.lastCommunicationTrace {
print(trace.description)
// Shows: operation, MUID, resource, duration, result, errors
}If you're using CIManager or PEManager directly, see the Migration Guide for step-by-step instructions to migrate to MIDI2Client.
- High-Level Responder API:
MIDI2ResponderClientactor for easy creation of MIDI-CI Responders - Connection Policy:
MIDI2ConnectionPolicywithMatchRule-based filtering (.contains(),.exact(),.prefix(),.suffix()) - Notification Throttling:
PEResponder.minNotifyIntervalto prevent notification storms (default: 50ms) - ListResource auto-totalCount: Automatic
totalCountheader in list responses - ComputedResource typed initializers: Type-safe
getTyped/setTypedCodable handlers - 602 Tests: Comprehensive test coverage (+38 from v1.0.15)
// Create responder with typed resources
let responder = try MIDI2ResponderClient(name: "MyResponder")
await responder.addResource("Temperature") {
ComputedResource(getTyped: { _ in
TemperatureReading(celsius: getCurrentTemp())
})
}
// Filter connections
var config = MIDI2ClientConfiguration()
config.connectionPolicy = MIDI2ConnectionPolicy(
allowedNames: [.prefix("iPad"), .contains("Pro")]
)
try await responder.start()- Superseded by v1.0.17 (rebase integration fix)
- UMP SysEx7 Bidirectional Conversion: MIDI 1.0 SysEx ↔ UMP Data 64 packet conversion with
UMPTranslator - Multi-Packet SysEx7 Reassembly: Actor-based
UMPSysEx7Assemblerfor fragmented SysEx messages with timeout handling - RPN/NRPN → MIDI 1.0 CC Conversion: Convert UMP RPN/NRPN to MIDI 1.0 Control Change approximations
- Factory API: Convenient
UMP.sysEx7.*methods for creating Data 64 packets - 564 Tests: Comprehensive test coverage across 77 test suites
- KORG ChannelList/ProgramList Auto-Conversion: Auto-convert KORG proprietary format (
bankPC: [Int]array) to standard format - New APIs:
getChannelList(),getProgramList()- Auto-detect vendor and select appropriate resource
- KORG Optimization: Skip ResourceList, directly fetch X-ParameterList (99% faster, 16.4s → 144ms)
- Adaptive WarmUp Strategy: Learn success/failure per device, auto-select optimal warmup strategy
- KORG Extension APIs:
getOptimizedResources(),getXParameterList(),getXProgramEdit()
- AsyncStream fixes: Race condition fixes in CoreMIDITransport, MockMIDITransport, LoopbackTransport, PESubscriptionManager
- Critical bug fix: Fixed CIManager.events not firing (AsyncStream continuation race condition)
See CHANGELOG.md for details.
See CHANGELOG.md for version history and recent updates.
- DeviceInfo: ✅ Works reliably
- ResourceList:
⚠️ May timeout due to chunk loss (physical layer limitation) - Auto-Retry: ✅ MIDI2Client automatically retries with fallback destinations
KORG devices use a non-standard PE format. MIDI2Kit handles this automatically, but multi-chunk responses (like ResourceList) may be unreliable over BLE MIDI due to packet loss.
Built-in optimizations (when using .korgBLEMIDI preset):
- Warm-up request before ResourceList to establish stable BLE connection
- Automatic destination fallback on timeout
- Extended timeout for multi-chunk responses
Workaround (if ResourceList still fails):
// Access known resources directly instead of using ResourceList:
let response = try await client.get("CMList", from: device.muid)
let response = try await client.get("ChannelList", from: device.muid)MIDI2Kit is organized into 5 modular Swift Package Manager targets with clear dependency hierarchy:
MIDI2Core (Foundation - no dependencies)
↑
├─ MIDI2Transport (CoreMIDI abstraction)
├─ MIDI2CI (Capability Inquiry / Discovery)
├─ MIDI2PE (Property Exchange)
└─ MIDI2Kit (High-Level API)
| Module | Purpose | Key Types |
|---|---|---|
| MIDI2Core | Foundation types, UMP messages, constants, bidirectional conversion | MUID, DeviceIdentity, UMPMessage, UMPTranslator, UMPSysEx7Assembler, Mcoded7 |
| MIDI2Transport | CoreMIDI integration with connection management | CoreMIDITransport, MIDITransport, SysExAssembler |
| MIDI2CI | MIDI Capability Inquiry protocol (device discovery) | CIManager, DiscoveredDevice, CIMessageParser |
| MIDI2PE | Property Exchange (GET/SET device properties) | PEManager, PETransactionManager, PESubscriptionManager |
| MIDI2Kit | High-Level API - unified client for common use cases | MIDI2Client, MIDI2ResponderClient, MIDI2Device, MIDI2ConnectionPolicy |
Actor-Based Concurrency: All managers are actor types for thread-safe isolation. All data types are Sendable. Swift Concurrency (async/await) is used throughout.
Request ID Management: PE supports max 128 concurrent requests (0-127) with automatic ID allocation, per-device inflight limiting, and request ID cooldown to prevent delayed response mismatches.
MIDI2Kit includes comprehensive tests covering:
- Unit Tests: 602 tests across 77+ test suites for individual components
- Integration Tests: End-to-end workflow tests including discovery, PE operations, error recovery
- UMP Conversion Tests: Bidirectional SysEx conversion, multi-packet reassembly, RPN/NRPN conversion
- Real Device Tests: Verified with KORG Module Pro and BLE MIDI devices
Run tests with:
swift testMIDI2Kit follows secure coding practices:
- ✅ Swift 6 strict concurrency mode
- ✅ Actor isolation for thread safety
- ✅ Input validation (MUID, Mcoded7, PE requests)
- ✅ Buffer size limits (DoS prevention)
- ✅ Structured error handling with classification
- ✅ Minimal external dependencies
See docs/security-audit-20260204.md for detailed security audit report.
MIT License - see LICENSE file for details.
Contributions welcome! Please open an issue first to discuss proposed changes.
- KORG Optimization Guide: docs/KORG-Optimization.md - 99% faster PE operations with KORG devices (v1.0.8+)
- Migration Guide: docs/MigrationGuide.md - Migrate from low-level API to MIDI2Client
- MIDI-CI Ecosystem Analysis: docs/MIDI-CI-Ecosystem-Analysis.md - Comparison with other MIDI-CI implementations
- KORG Compatibility Notes: docs/KORG-Module-Pro-Limitations.md - Known issues and workarounds
- Code Review Reports: docs/code-review-*.md - Detailed code quality reviews