MIDI
MIDI is the protocol that knob boxes, keyboards, pad grids, and sequencers speak. Because those devices speak it, you can connect one to a sketch. A hardware fader can then drive a parameter, and a key can trigger an event. A sketch can also send notes and control changes back out. MIDI support lives in a separate library, so the drawing core stays free of Core MIDI. Add import OllinMIDI beside import Ollin to use it.
You read incoming MIDI with a MIDIInput and send it with a MIDIOutput. Both run over Apple's Core MIDI. A message is a kind, such as a note, a control change, or a clock tick. Each message arrives on a channel (1 to 16). Ollin parses and encodes the MIDI 1.0 format from the specification, so no third-party code is vendored.
ch 1 controlChange controller 7 value 64
└ channel ┘ └─ kind ─┘ └──────── data ────────┘
The usual pattern is to create the input in setup() and read it in draw().
import Ollin
import OllinMIDI
final class Wired: Sketch {
let midi = MIDIInput()
override func setup() { try? midi.start() }
override func draw() {
// a knob on CC 7 (0…127) sets the radius
let level = midi.controlValue(7, default: 0)
drawCircle(width / 2, height / 2, (40 + Double(level) / 127 * 300) * scale)
}
}
Contents
- MIDIMessage & kinds - the value you read and send
- MIDIInput - read incoming MIDI three ways
- Binding to a
@Param- drive a parameter from a controller - Per-note expression (MPE) - a controller that bends, presses, and slides each note on its own
- Tempo sync (TempoClock) - lock motion to the beat of whatever is playing
- Timecode (TimecodeClock) - chase a timeline sent as MIDI Time Code
- MIDIOutput - send notes and control changes
- Testing without hardware - loopback and the monitor
- Files, not cables - where a
.midfile is read and written
MIDIMessage & kinds
A MIDIMessage holds a kind and the channel (1 to 16) it arrived on. The kind carries its own data:
switch message.kind {
case .noteOn(let note, let velocity): trigger(note, velocity)
case .noteOff(let note, _): release(note)
case .controlChange(let cc, let value): knobs[cc] = value
case .pitchBend(let value): bend = value // 0…16383, 8192 center
default: break
}
The common kinds are .noteOn / .noteOff (a key or pad), .controlChange (a knob, fader, or pedal), and .pitchBend. The other kinds are .programChange, .channelPressure, .polyPressure, and the system sync messages .clock / .start / .stop / .continue / .songPosition. The sync messages carry no channel, so they report 0, and the tempo clock reads them for you. A note-on with velocity 0 becomes .noteOff, because many devices send that form for a release.
A .timecodeQuarterFrame(piece:value:) is one piece of a MIDI Time Code position; a TimecodeClock reads those for you.
The convenience accessors cover the common reads, so you rarely need to switch on kind directly:
message.note // Int? (note and poly-pressure messages)
message.velocity // Int? (note messages)
message.controller // Int? (the CC number)
message.value // Int? (CC value, program, pressure, or bend)
message.isNoteOn // Bool
message.isNoteOff // Bool (a note-off, or a note-on at velocity 0)
To send one, build it: MIDIMessage(_:channel:) from a kind, or MIDIMessage(status:data1:data2:) from the raw bytes, or MIDIMessage(umpWord:) from a MIDI 2.0 Universal MIDI Packet word. umpWord writes it back out in that form, which is what the timecode walk steps through.
MIDIInput
MIDIInput(name: String = "Ollin")
func start() throws // MIDIError.coreMIDI(status) if Core MIDI refuses
func stop()
func availableSources() -> [MIDIEndpoint] // the connected devices: `name`, `manufacturer`, and an `id`
// 1. Latest value of a controller (continuous knobs/faders)
func controlValue(_ controller: Int, channel: Int? = nil) -> Int?
func controlValue(_ controller: Int, default: Int, channel: Int? = nil) -> Int
// 2. State of a note right now (held keys/pads), and where the pitch wheel is
func isNoteOn(_ note: Int, channel: Int? = nil) -> Bool
func pitchBend(channel: Int? = nil) -> Double // -1 fully down, 0 at rest, 1 fully up
// 3. Everything since the last call, in order (discrete events)
func messages() -> [MIDIMessage]
start() connects to every MIDI device on the system. A device plugged in later connects automatically. Then you read what arrives in one of three ways, depending on what you need. Omit channel to read across all channels, or pass 1…16 to read one channel only.
The latest value of a controller, for a continuous knob or fader. Read it again each frame:
let radius = Double(midi.controlValue(7, default: 0)) / 127 * 300
The state of a note, for a held key or pad:
if midi.isNoteOn(60) { sustain() }
The pitch wheel reads as a fraction of its travel: -1 fully down, 0 at rest, and 1 fully up. It reads 0 until the wheel moves, since a wheel springs back to its center. Without a channel it reads the channel whose wheel moved last:
rotate(midi.pitchBend() * .pi / 4) // the wheel leans the drawing up to 45° each way
That is the whole channel's wheel. On a controller that bends each note on its own, each note's bend is in heldNotes.
The event queue, for discrete events such as struck notes, transport, and clock. messages() returns everything received since the last call, in arrival order, and then clears the queue. Call it once per frame:
for message in midi.messages() where message.isNoteOn {
spawnRipple(pitch: message.note ?? 0, force: message.velocity ?? 0)
}
Core MIDI thread main thread (draw)
┌────────────┐ controls[] ┌────────────────────┐
│ packet → │ ───────────→ │ midi.controlValue │ continuous
│ parse + │ held notes │ midi.isNoteOn │ state
│ stash │ inbox[] │ midi.messages() │ discrete
└────────────┘ ───────────→ └────────────────────┘
MIDI arrives on a Core MIDI thread, and the sketch reads it on the main thread. Every shared value is held behind a lock, so you can read them safely from draw().
Binding to a @Param
func bind(controlChange controller: Int, to param: Param<Double>,
channel: Int? = nil, from input: ClosedRange<Double> = 0...127)
func unbind(controlChange controller: Int, channel: Int? = nil)
to: also takes a Param<Tempo>, the beats-per-minute slider a tempo declares, mapped into its range the same way. The beats per bar stay what the declaration gave them.
The fourth way to read is to bind a control-change knob directly to a @Param. A hardware fader then drives the same parameter that the inspector slider does. Ollin maps each incoming value from input, a controller's 0…127 by default, into the parameter's own range, then assigns it:
@Param(20...400) var radius = 120.0
override func setup() {
try? midi.start()
midi.bind(controlChange: 7, to: $radius) // CC 0…127 → 20…400
}
A bound parameter updates on its own as messages arrive, so you do not read it each frame. The same parameter still works from the inspector slider and from code. Whichever source moved it most recently sets the value.
The pitch wheel binds the same way, about its rest:
func bindPitchBend(to param: Param<Double>, channel: Int? = nil,
travel: ClosedRange<Double> = -1...1)
func unbindPitchBend(channel: Int? = nil)
travel is the part of the wheel's travel that spans the parameter's range, on the scale pitchBend() reads. By default it is the whole travel. The wheel at rest then holds the parameter at the middle of its range, and each end of the wheel reaches that end of the range. Pass 0...1 and only an upward bend moves the parameter, from the bottom of its range at rest to the top. A bend outside travel holds the parameter at its end. to: takes a Param<Tempo> here too.
let midi = MIDIInput()
@Param(-30...30) var lean = 0.0
@Param(0...1) var glow = 0.0
override func setup() {
try? midi.start()
midi.bindPitchBend(to: $lean) // at rest 0, the ends -30 and 30
midi.bindPitchBend(to: $glow, channel: 2, travel: 0...1)
}
The wheel sends fourteen bits: 8,192 steps below its rest and 8,191 above. The binding divides each side by its own count, so the rest lands exactly on the midpoint and both ends reach the range's ends.
Smoothing the moves. Give the @Param a smoothing:, and the value glides to each new position instead of jumping. Smoothing is a property of the parameter, so it applies whether the value comes from MIDI, OSC, or a drag of the inspector slider:
@Param(20...400, smoothing: .eased(0.3)) var radius = 120.0 // 0.3s glide
@Param(0...1, smoothing: .smoothed) var mix = 0.5 // adaptive 1€ filter
.eased glides to each value over a fixed time along an Easing curve. .smoothed passes the value through a OneEuroFilter, which holds steady while the knob is still and responds faster as the knob moves. That response usually feels better than .eased for a hand on live hardware. Both are documented in Animation, together with the Easing curves and the @Smoothed 1€ filter.
Per-note expression (MPE)
var heldNotes: [HeldNote] // every held note, oldest first, its expression resolved
var mpeZones: [MPEZone] // the zones in force: what the controller announced, or what you set
struct HeldNote {
let note: Int // the key, 0…127
let channel: Int // 1…16
let velocity: Double // how hard it was struck, 0…1
let pitchBend: Double // semitones, the channel's range applied
var pitch: Double // note + pitchBend, the MIDI number sounding
let pressure: Double // 0…1, zero until the controller says
let slide: Double // 0…1, controller 74, half way until the controller says
var id: Int // the note and its channel together
}
MPEZone.lower(members: Int = 15) // channel 1 is the master, 2 up to 16 carry notes
MPEZone.upper(members: Int = 15) // channel 16 is the master, 15 down carry notes
A keyboard bends every note at once, because its wheel speaks for the whole channel. A polyphonic-expression surface (a Seaboard, a LinnStrument, a phone app that speaks MPE) puts each note on a channel of its own. The channel's pitch bend, pressure, and controller 74 then belong to that one note. heldNotes reads it that way: every note that is down, with what the controller has said about it since, resolved for the channel it arrived on.
for note in midi.heldNotes {
drawCircle(x(note.pitch), y(note.slide), 20 + 60 * note.pressure)
}
On a plain keyboard the same read gives the wheel and the aftertouch, shared by every note on the channel. A key's own pressure (polyphonic aftertouch) counts where the keyboard sends it. Nothing changes in the sketch.
Zones. MPE divides the sixteen channels into a lower zone running up from channel 1 and an upper zone running down from 16. Most controllers use the lower one alone. The channel at the edge is the master. What arrives there (a bend, a pressure, a slide) applies to every note in the zone, on top of each note's own. A controller announces its zone with a configuration message on the master channel, and the input lays itself out from it. So mpeZones is usually nothing to set. For a controller that does not announce, set it by hand: midi.mpeZones = [.lower()], or build one field by field with MPEZone(masterChannel:memberChannels:…). A zone reads back what it is: masterChannel is the channel the shared messages arrive on, memberChannels the range the notes arrive on and memberCount how many that is, isLower says which end of the sixteen it runs from, and isMember(channel:) answers whether a channel carries notes, with contains(channel:) counting the master too.
Bend ranges. A member channel's bend spans 48 semitones and the master's 2, the specification's defaults, held on the zone as memberPitchBendRange and masterPitchBendRange. A quarter of the wheel on a member channel is then twelve semitones. A controller that says otherwise (the pitch bend range as a registered parameter) is believed. On a member channel it sets every member's range, on the master the master's. On a channel outside any zone it sets that channel's, which is 2 semitones until told.
Playing an instrument from it. Start each note as it appears, let it go as it leaves, and every frame hand each held note the three values. That is the whole wiring; Synth does the rest.
var playing: [Int: PlayingNote] = [:]
override func draw() {
let held = midi.heldNotes
for note in held where playing[note.id] == nil {
playing[note.id] = synth.noteOn(Pitch(Double(note.note)), velocity: note.velocity)
}
for (id, note) in playing where !held.contains(where: { $0.id == id }) {
synth.noteOff(note)
playing[id] = nil
}
for note in held {
guard let playing = playing[note.id] else { continue }
synth.bend(playing, semitones: note.pitchBend)
synth.press(playing, note.pressure)
synth.slide(playing, note.slide)
}
}
A strike shorter than a frame is not in heldNotes by the time draw() reads it. A pad played that way is a discrete event, which is what messages() is for.
controlValue(74) with no channel is whichever channel moved last, which on a surface is whichever finger moved last. The per-note value is on the held note.
The Expression example (Examples/Audio/Expression) is a surface the mouse plays through a virtual source, so a bend, a press, and a slide cross Core MIDI the way a controller's do. A real controller plugged in joins the same picture.
Tempo sync (TempoClock)
TempoClock(from: MIDIInput)
var tempo: Double // received BPM (smoothed); 0 until measured
var isPlaying: Bool // the position is advancing
var isReceiving: Bool // a clock tick arrived within the last second
var beats: Double // musical time in quarter notes: 2.5 is halfway through beat 3
var beatCount: Int // whole beats, from 0
var phase: Double // 0…1 through the current beat
var beat: Double // 0…1 pulse: snaps to 1 on each beat, decays over ~0.25 s
var timeSinceBeat: Double // seconds since the last beat landed
var beatsPerBar: Int // the meter you declare for the bar reads (default 4)
var bar: Int // whole bars, from 0
var barPhase: Double // 0…1 through the current bar
func progress(over length: Double, phase: Double = 0) -> Double // 0…1 ramp across any beat span
A TempoClock locks a sketch's motion to whatever is playing, so the visuals follow the music the way a VJ set does. Anything that sends MIDI clock can lead: a DAW, a drum machine, a hardware sequencer, or a DJ mixer. The clock reads the sync messages from a MIDIInput and turns them into musical time that you read in draw(). So animation lands on the beat instead of near it.
let midi = MIDIInput()
lazy var clock = TempoClock(from: midi)
override func setup() { try? midi.start() }
override func draw() {
background(.black)
drawCircle(width / 2, height / 2, (120 + 50 * clock.beat) * scale) // throbs on the beat
let angle = clock.progress(over: 8) * .tau // one lap every 8 beats
drawCircle(width / 2 + cos(angle) * 300, height / 2 + sin(angle) * 300, 20 * scale)
}
beat is the ready-made pulse. It has the same shape as the audio analyzer's beat. A beat-reactive sketch can therefore switch between the audio it hears in the room and the MIDI clock it receives. phase is the position inside the current beat. progress(over:) is the musical-time counterpart of loopProgress(over:). It is a 0…1 ramp across any number of beats, for a move that spans a phrase rather than a single beat.
How it handles a real-world clock. MIDI clock ticks 24 times per beat. The position comes from counting those ticks, so the beat grid cannot drift however much the tempo varies. The tempo is a mean over a sliding window of recent tick intervals. The window holds about two beats of ticks, which is enough to flatten the millisecond-scale jitter that is typical of a MIDI connection. The tempo is used only to glide phase between ticks. That glide is clamped, so the position never runs backward when a tick is late. A large tempo jump clears the window, so the new tempo locks in within a beat.
Transport. start resets the position to zero, and the next tick is the downbeat. stop freezes the position, but the tempo keeps updating if clock keeps arriving. continue resumes from where the position froze, or from a received song position. A master that sends only clock and no transport messages (common on DJ gear) free-runs, and its first tick counts as beat zero. MIDI clock carries no meter, so you declare beatsPerBar (default 4), and bar zero is wherever the count began.
Keyframes on musical time. A Timeline whose durations are written in beats runs on the clock, and one line is enough to set that up. It follows clock.beats instead of advancing in seconds.
let swell = Timeline(0.0).to(1.0, in: 3, curve: .easeOut).to(0.0, in: 1) // durations in beats
override func draw() {
swell.loops = true
swell.seek(to: clock.beats) // now it cycles every 4 beats, locked to the music
}
The Tempo example (Examples/Integration/Tempo) shows this with no hardware. An internal timer sends clock through a virtual source, and the visuals lock to it. Connect real gear to the Mac, and the same sketch follows that clock instead.
Timecode (TimecodeClock)
TimecodeClock(from: MIDIInput)
var timecode: Timecode? // the frame the timeline is on; nil until any arrives
var seconds: Double // the position in seconds, gliding between frames
var frameRate: Timecode.FrameRate? // 24, 25, 29.97 drop, or 30, once the sender has said
var isPlaying: Bool // quarter frames arrived within the last half second
var isReceiving: Bool // any timecode arrived within the last second
var isReversed: Bool // the pieces run backward: a deck shuttling in reverse
Timecode(hours:minutes:seconds:frames:frameRate:)
Timecode(frameNumber:frameRate:) // and Timecode(seconds:frameRate:)
var frameNumber: Int // frames from zero, the dropped numbers not counted
var totalSeconds: Double // the same on the wall clock
var fullFrameSysEx: [UInt8] // the whole position as one system-exclusive message
func advanced(byFrames frames: Int) -> Timecode
cueIn < cueOut // Comparable, by the moment it names
(cueIn...cueOut).contains(code) // so a cue window is a range
"\(code)" // "01:02:03:04", or "00:10:00;02" for drop frame
rate.framesPerSecond // 24, 25, 29.97, or 30
rate.secondsPerFrame // one frame's length on the wall clock
rate.isDropFrame // true for 29.97 drop, where two numbers a minute go unused
Where a tempo clock says how fast, timecode says where. A video deck, a show controller, a lighting desk, or a DAW locked to picture sends its position as MIDI Time Code. That is hours, minutes, seconds, and frames, at one of four frame rates. A TimecodeClock reads it, so a sketch can chase the same timeline and land a cue on the frame:
let midi = MIDIInput()
lazy var timecode = TimecodeClock(from: midi)
override func setup() { try? midi.start() }
override func draw() {
background(.black)
let t = timecode.seconds // 90.48 when the deck reads 00:01:30:12 at 25
fill(t >= 90 && t < 92 ? .white : Color(white: 0.3)) // a cue at a minute and a half
drawCircle(width / 2, height / 2, 120 * scale)
drawText(timecode.timecode.map { "\($0)" } ?? "--:--:--:--", 40 * scale, 60 * scale)
}
How it reads the wire. A running sender spells its position in eight quarter-frame messages, four to a frame, so a whole time arrives every two frames. The clock counts every message as a quarter of a frame in the direction the pieces run, and re-anchors on each completed set. A deck shuttling backward sends them in reverse, and the position runs backward with it. seconds is therefore exact at every message and glides at the frame rate between them. The glide stops just short of the next message, so a late one never runs it backward. A full-frame message, the one a deck sends when it locates or stops, sets the position outright. Half a second of silence means the transport stopped, and the position holds where it was.
Cue points are comparisons. A Timecode is Comparable, so a cue test is < and a window is a range, with no detour through totalSeconds:
let cueIn = Timecode(hours: 0, minutes: 1, seconds: 30, frames: 0, frameRate: .fps25)
let cueOut = cueIn.advanced(byFrames: 50) // two seconds later at 25
if let now = timecode.timecode, (cueIn..<cueOut).contains(now) { drawTitle() }
cues.sorted() // in the order they play
The comparison runs on the wall clock, so two timecodes counted at different rates still answer honestly about which came first, even where the larger frame number is the earlier moment. Two that name the very same moment at different rates are ordered by rate, which is what keeps the order total: < and == never disagree.
Frame rates. Timecode.FrameRate is the four the protocol names: .fps24, .fps25, .fps30Drop, and .fps30. Drop frame counts thirty frames a second over video that runs at 29.97. It skips frame numbers 0 and 1 at the top of every minute except each tenth, so the labels stay on the wall clock. frameNumber and totalSeconds account for that, and a drop-frame timecode prints with a semicolon before the frames, the way a broadcast display does.
Sending it. MIDIOutput.send(timecode:) sends a position whole as a full-frame message. send(.timecodeQuarterFrame(piece:value:)) with Timecode.quarterFrameValue(piece:) spells one out, and send(sysEx:) under both sends any system exclusive. The Timecode example (Examples/Integration/Timecode) plays the deck itself. An internal timer sends the quarter frames through a virtual source, a TimecodeClock follows them, and cues flash under a scrolling timeline. Point a real sender at the Mac and the same sketch chases that.
MIDIOutput
MIDIOutput(name: String = "Ollin")
func open(matching: String? = nil) throws // a hardware destination (first matching name)
func openVirtual(named: String? = nil) throws // a virtual source other apps receive from
func availableDestinations() -> [MIDIEndpoint]
func send(_ message: MIDIMessage)
func noteOn(_ note: Int, velocity: Int = 100, channel: Int = 1)
func noteOff(_ note: Int, velocity: Int = 0, channel: Int = 1)
func controlChange(_ controller: Int, value: Int, channel: Int = 1)
func pitchBend(_ value: Int, channel: Int = 1) // 0…16383, 8192 at rest
func channelPressure(_ pressure: Int, channel: Int = 1) // aftertouch for the channel
func polyPressure(_ note: Int, pressure: Int, channel: Int = 1) // one key's pressure
func registeredParameter(_ parameter: Int, value: Int, fine: Int = 0, channel: Int = 1)
func send(mpeZone: MPEZone) // announce a zone the way a controller does
func send(sysEx body: [UInt8]) // a system exclusive, the bytes between its start and end
func send(timecode: Timecode) // a position whole, as a full-frame timecode message
func close()
open(matching:) connects to a hardware destination. It takes the first destination whose name contains the text you pass, or the first available destination when you pass nothing. Then you send from draw():
let out = MIDIOutput()
override func setup() { try? out.open(matching: "Grid") } // first destination matching "Grid"
override func draw() { out.controlChange(7, value: Int(level * 127)) }
openVirtual(named:) instead publishes a virtual source that other apps can receive from. A MIDIInput in the same process can receive from it too, which is how the loopback below runs with no hardware.
Testing without hardware
You can test MIDI with nothing but the Mac in front of you. The MIDILoopback example (Examples/Integration/MIDILoopback) opens a virtual source, sends control changes to itself, and draws the value it reads back. What you see on screen is the round trip. It also shows parameter smoothing. The incoming value arrives in jumps, and the circle glides toward each new value.
To bring in real gear, connect a controller and run the MIDIMonitor example (Examples/Integration/MIDIMonitor). It listens to every device and prints and draws each message, so you can find out what a knob or pad sends by touching it. You can reconfigure many controllers, especially the endless-encoder kind, in their own editor software. The monitor shows what yours is set to.
Files, not cables
This page is about gear playing live. A .mid file on disk is the other thing the word MIDI names, and it lives one library over, in MIDI files: MIDIFile reads a Standard MIDI File into notes a sketch plays or draws, and writes one back out, so a phrase a sketch worked out opens in a sequencer.
It sits in OllinAudio because a file arrives as music rather than as messages. Its notes are ScheduledNote values, which is what a Synth plays and what every pattern type in Composition hands back, so nothing has to be translated in between.
See the MIDILoopback example for a sketch that sends and receives with no hardware. Use MIDIMonitor to inspect messages from a real controller, Tempo for visuals locked to MIDI clock, and Timecode for a sketch that chases a timeline.