Apple's GameController framework is the cleanest gamepad API on any platform. It abstracts the messy HID layer behind a typed Swift surface, fans out IOKit notifications as NSNotification events, and — since macOS 13 — exposes the full DualSense adaptive trigger model. If you are building a gamepad-to-MIDI bridge on the Mac, you start here. Nothing else is worth your time.
- Use
GCController+GCExtendedGamepad. They cover DualSense, Xbox, MFi, and Switch Pro on macOS 11+. - Snapshot model —
valueChangedHandlerfires every frame; diff against the last snapshot to emit MIDI deltas. - Adaptive triggers require macOS 13 +
GCDualSenseAdaptiveTrigger. Weapon, Feedback, and Vibration modes are all exposed. - Latency budget: ~1.5 ms USB, ~4 ms BT — well under MIDI's ~3 ms perceptual threshold.
Why GameController beats raw IOKit HID
You can talk to a DualSense via raw IOHIDManager — and for the first thirty minutes it feels powerful. Then you discover that buttons live at byte offsets that change between firmware revisions, that BT and USB report formats are different, that the touchpad finger count lives in a packed 4-bit field. The Apple GameController framework handles all of that for you, normalises the layout, and gives you stable Swift types. Use it.
Bootstrapping a controller listener
Two notifications matter: GCControllerDidConnect and GCControllerDidDisconnect. Subscribe at app launch and you handle the case where the user plugs in mid-session — which they will, mid-set.
import GameController
import CoreMIDI
final class ControllerBridge {
private var midiClient = MIDIClientRef()
private var midiSource = MIDIEndpointRef()
init() {
MIDIClientCreate("UniversalControllerMIDI" as CFString, nil, nil, &midiClient)
MIDISourceCreate(midiClient, "Gamepad" as CFString, &midiSource)
NotificationCenter.default.addObserver(
self, selector: #selector(connected(_:)),
name: .GCControllerDidConnect, object: nil)
GCController.startWirelessControllerDiscovery {}
}
@objc private func connected(_ note: Notification) {
guard let pad = (note.object as? GCController)?.extendedGamepad else { return }
pad.valueChangedHandler = { [weak self] gamepad, element in
self?.emitMIDI(from: gamepad)
}
}
}
That is the entire control-flow scaffold. Every input frame fires valueChangedHandler with the whole gamepad snapshot. You then diff against the previous frame and emit MIDI for any element that crossed a threshold. Universal Controller MIDI runs exactly this loop under the hood — see the product overview for the user-facing side.
Mapping inputs to MIDI
Sticks are bipolar floats in [-1.0, 1.0]. Triggers are [0.0, 1.0]. Buttons expose .isPressed as Bool plus a .value float for analog buttons. The minimum-viable mapping looks like this:
private func emitMIDI(from pad: GCExtendedGamepad) {
// Cross button → Note 60 (Middle C)
if pad.buttonA.isPressed != last.crossPressed {
send(noteOn: 60, velocity: pad.buttonA.isPressed ? 127 : 0)
last.crossPressed = pad.buttonA.isPressed
}
// Left stick X → CC 1
let lx = UInt8((pad.leftThumbstick.xAxis.value * 63.5 + 63.5).clamped(0, 127))
if lx != last.lx { send(cc: 1, value: lx); last.lx = lx }
// L2 trigger → CC 7
let l2 = UInt8((pad.leftTrigger.value * 127).clamped(0, 127))
if l2 != last.l2 { send(cc: 7, value: l2); last.l2 = l2 }
}
Note the explicit diffing — without it, you would saturate the MIDI bus with redundant CCs at 250 Hz. The cost of an if per element is irrelevant; the cost of spamming Ableton with 1500 CC msgs/sec is not.
Adaptive triggers — the macOS 13+ magic
The DualSense's adaptive triggers were a black box on the Mac until macOS Ventura. Apple shipped GCDualSenseAdaptiveTrigger with four modes: off, feedback, weapon, and vibration. Each takes a start position, end position, and strength. Wire a MIDI CC inbound to set those and you have haptic feedback driven by your DAW. The full bidirectional pattern is covered in our adaptive trigger feedback guide.
// Set L2 to "weapon" mode — resistance until break point, then snap
let l2 = pad.dualSense?.leftTrigger as? GCDualSenseAdaptiveTrigger
l2?.setModeWeaponWithStartPosition(0.2, endPosition: 0.6, resistiveStrength: 0.8)
// Or: continuous resistance with vibration on a CC value
l2?.setModeVibrationWithStartPosition(
0.0, amplitude: midiCC / 127.0, frequency: 80.0) Mode comparison table
| Mode | Parameters | Feel | MIDI source |
|---|---|---|---|
| off | — | Free travel | Default |
| feedback | start, strength | Constant resistance from a point | Filter resonance CC |
| weapon | start, end, strength | Resistance + click break | Compressor threshold |
| vibration | start, amplitude, frequency | Pulsing rumble in the trigger | Sidechain envelope |
Latency, threads, and the run loop
valueChangedHandler is called on the main thread by default. That is fine for a UI app, lethal for a real-time MIDI bridge — the moment your UI redraws, you eat 16 ms. Move the MIDI send off the main thread with a serial dispatch queue. MIDIPacketList sends are thread-safe as long as you do not share the buffer.
private let midiQueue = DispatchQueue(label: "midi.tx", qos: .userInteractive)
pad.valueChangedHandler = { [weak self] gamepad, _ in
self?.midiQueue.async { self?.emitMIDI(from: gamepad) }
} With this in place the bridge sits at ~1.5 ms USB-C latency and ~4 ms over Bluetooth — comfortably below the ~3 ms perceptual threshold for MIDI on USB and within the 5–7 ms range that humans struggle to feel. See the latency benchmark for measured numbers.
Gotchas worth knowing
- BT discovery is opt-in — you must call
startWirelessControllerDiscoveryeven if the user already paired the controller in System Settings. - Sandbox — Mac App Store sandbox requires the
com.apple.security.device.bluetoothentitlement for BT. USB is free. - Touchpad on DualSense is not surfaced by
GCExtendedGamepad. You needGCDualSenseGamepad(macOS 12+) andtouchpadButton+touchpadPrimary/Secondary. - The Switch Pro and Joy-Cons connect as MFi controllers on macOS 11.3+ but their gyro/accelerometer exposure is partial — Apple's frame is "extended gamepad", not "the full hardware".
- Apple silicon vs Intel — no input-layer differences, but Bluetooth chipset latency varies. M-series Macs are noticeably tighter.
GameController framework is one of the very few Apple APIs that ages well. It has been stable since 2013, the DualSense additions arrived without breaking the surface, and the Swift ergonomics are clean. If you are shipping on macOS, do not fight it — let it carry the input layer and spend your engineering time on the MIDI mapping UX. That is what Universal Controller MIDI is built on, and the result is a bridge that ships in ~3 MB with no extra drivers.