Skip to content
macOSDeep Dive Published Updated 7 min readViews unavailable

Core MIDI on macOS: Client, Port, Endpoint, and Callback Lifecycles

Build Core MIDI clients with explicit endpoint discovery, protocol-aware ports, real-time-safe callbacks, hot-plug recovery, and orderly disposal.

Core MIDI exposes system MIDI sources and destinations through a client, ports, and endpoints. A client represents an app’s relationship with the MIDI service; an input port receives from connected sources, and an output port sends to destinations. These objects have different lifetimes. A device can disappear while a port remains allocated, and a callback can arrive on a high-priority thread where ordinary UI or blocking work is unsafe.

For production software, separate endpoint discovery, routing policy, packet parsing, and musical state. Keep references to objects the app owns, refresh the endpoint inventory when the system reports changes, and re-resolve the user’s selected route instead of assuming an endpoint index is stable. MIDI is a message transport; it does not define how a particular controller maps a note or control to application behavior.

Create a client and handle system notifications

Create a MIDIClientRef once for the service object that owns MIDI work. The client notification callback reports system changes such as object additions or removals. Apple’s block-based client creation API invokes its notification block on an arbitrary thread, so protect internal state and hand changes to a serialized queue before updating the endpoint model. Do not call AppKit from this callback.

import CoreMIDI
import CoreFoundation
import Foundation

final class MIDIClientOwner {
    private var client = MIDIClientRef()
    private let stateQueue = DispatchQueue(label: "midi.state")

    func start() throws {
        let status = MIDIClientCreateWithBlock("Example MIDI Client" as CFString, &client) {
            [weak self] _ in
            self?.stateQueue.async {
                self?.refreshEndpoints()
            }
        }
        guard status == noErr else { throw NSError(domain: NSOSStatusErrorDomain, code: Int(status)) }
    }

    func stop() {
        guard client != 0 else { return }
        MIDIClientDispose(client)
        client = 0
    }

    private func refreshEndpoints() {
        // Rebuild a value snapshot from the current Core MIDI topology.
    }
}

The example illustrates client ownership and the callback thread boundary. A real implementation should record the status of disposal and arrange that no callback can access released state. Keep initialization idempotent; constructing a second client for each view appearance can duplicate notifications and ports.

Enumerate endpoints by identity, not array position

Core MIDI exposes source and destination endpoints through system object queries. Names help people choose a route, but two devices can share a name and a device can be unplugged and recreated. Keep endpoint object references while they remain valid, display manufacturer/model information when available, and re-enumerate after object-change notifications. Persist user intent using a carefully designed route preference, then resolve it against the current topology.

Endpoint enumeration is a snapshot. A device can disappear between listing and connecting a source. Treat MIDIPortConnectSource and send calls as operations that can fail, and make the UI recoverable if the route becomes unavailable. Do not use endpoint numeric IDs as permanent product identity across machines or indefinitely across topology changes.

Separate the physical endpoint list from the user’s logical routing choices. A musician may want “keyboard input” to follow a preferred device when it is present, but the app still needs to show when that route cannot be resolved. Store a preference using the stable properties the platform exposes and the app’s own confirmation history, then verify the live endpoint and ask again if multiple candidates match. Never turn a localized device name into a silent authorization decision.

Ports, protocols, and callback discipline

An input port can connect to multiple sources, while an output port can send to destinations. Create only the direction the product requires and dispose ports when the owning MIDI service stops. With modern Core MIDI APIs, the chosen protocol determines the packet format delivered to the callback. Core MIDI can convert between supported MIDI protocol formats when a port and endpoint differ, but the app still needs to understand the event semantics and preserve message ordering.

Input callbacks run on a separate high-priority thread. Keep callback work bounded and real-time-safe: inspect packet data, copy or enqueue compact values into a preallocated bounded buffer, then return. Avoid locks with unbounded contention, synchronous logging, heap-heavy model mutation, disk I/O, UI updates, or waiting on another queue. If a bounded queue is full, define whether to drop, coalesce, or count the event; silently allowing memory to grow converts a brief consumer stall into a latency failure.

Timestamp information is part of the event stream and can be important for sequencing. Preserve it when crossing queues. If a UI graph needs to show notes or controller values, publish summarized state at a deliberate refresh cadence rather than dispatching one UI task per raw MIDI event. For a musical output clock, use the timestamp model and APIs described by Core MIDI rather than wall-clock sleeps from a general-purpose thread.

MIDI 1.0 and MIDI 2.0 are not interchangeable byte arrays

MIDI 1.0 commonly represents events as status and data bytes. MIDI 2.0 uses Universal MIDI Packets and supports additional resolution and message forms. Core MIDI’s newer event-list APIs expose protocol-aware packets; older packet-list APIs remain present for compatibility. Use the API that matches your endpoint and deployment target, and parse according to the packet’s protocol. Do not cast a MIDI 2 packet buffer to a MIDI 1 byte sequence or assume every event is a three-byte note message.

Validate channel, group, data width, and message kind before applying a value. Running status and variable message sizes make hand-written parsing easy to get wrong. Prefer framework packet iteration helpers and official message constructors where appropriate. Treat incoming values as device input, not trusted commands: map them through an explicit application policy and do not permit arbitrary system actions from a MIDI message.

Do not make device topology changes part of the audio-critical message path. The notification callback should schedule an inventory refresh, while a separate routing coordinator decides whether an active input connection is obsolete. Likewise, a receive block should hand off data rather than querying endpoints or rebuilding a user interface. This separation lets a burst of notes continue to be processed even while the operating system adds or removes unrelated MIDI devices.

Choose a queue policy based on the data. A latest-value meter can coalesce repeated controller values, while note-on and note-off events generally cannot be dropped without changing the performance. Keep queue capacity explicit, count overload, and recover from a dropped sequence by resetting affected application state rather than leaving a note visually stuck. For output, serialize dependent events and handle OSStatus failures; a successful local send call is not an acknowledgement that a remote instrument rendered sound.

Teardown and hot-plug recovery

On shutdown, stop accepting new UI route changes, disconnect input sources, dispose input and output ports, and finally dispose the client. Clear callbacks and queued events associated with the old route generation. If the app can be reinitialized after a window closes, keep MIDI service lifetime independent of that window and manage it at an app or document scope intentionally.

For hot-plug, a topology notification should trigger a fresh inventory query, not direct assumptions about what changed based solely on one callback. Reconcile the selected endpoint, disconnect obsolete routes, and let the user know if the route disappeared. If the same device returns, reconnect only when product policy says that is expected and the endpoint can be identified safely.

Acceptance checks

Test client creation failure, no endpoints, duplicate endpoint names, source addition/removal during active input, output destination removal before send, callback bursts, consumer overload, route switch while events are queued, protocol-specific parsing, and repeated start/stop. Verify every created client and port is disposed exactly once and no old route can update a newly selected device’s UI.

Measure callback duration, queue occupancy, dropped events, endpoint refresh delay, send errors, and route recovery time. Test with virtual MIDI endpoints and physical interfaces, and record the OS version and protocol behavior used. A reliable Core MIDI layer treats endpoint discovery as changing topology and the receive callback as real-time-adjacent code with a strict budget.

Related:

Sources:

Comments