MIDI Basics¶
This tutorial covers MIDI fundamentals with coremusic, including sending, receiving, and processing MIDI messages.
Every example on this page is a runnable program under
examples/tutorials/midi_basics/.
Prerequisites¶
- coremusic installed and built
- Basic Python knowledge
- Optional: A MIDI controller or virtual MIDI device
Understanding MIDI¶
MIDI (Musical Instrument Digital Interface) is a protocol for communicating musical information:
- Note On/Off: When keys are pressed/released
- Control Change (CC): Knobs, sliders, pedals
- Program Change: Patch/preset selection
- Pitch Bend: Pitch wheel position
- Aftertouch: Pressure after key press
Endpoints, Ports, and Clients¶
Three object types cover everything in this tutorial:
MIDIClientowns everything else. Create one per application.MIDIPortis this application's connection to the MIDI system. An output port sends, an input port receives.MIDIEndpointis the other end of a connection: a source produces MIDI (a keyboard), a destination consumes it (a synth). Endpoints are published system-wide by whichever process owns them, so the endpoints you send to normally belong to some other application or device.
Sending means: output port, plus a destination endpoint to aim at.
MIDI Devices¶
Listing Devices¶
from coremusic.midi import get_destinations, get_sources
def list_midi_devices():
"""List all MIDI sources and destinations."""
sources = get_sources()
destinations = get_destinations()
print("MIDI System Overview:")
print(f" Sources (inputs): {len(sources)}")
print(f" Destinations (outputs): {len(destinations)}")
print()
print("MIDI Sources (Inputs):")
for i, source in enumerate(sources):
print(f" [{i}] {source.name or '<unknown>'}")
print()
print("MIDI Destinations (Outputs):")
for i, destination in enumerate(destinations):
print(f" [{i}] {destination.name or '<unknown>'}")
list_midi_devices()
If both lists are empty, no MIDI hardware or software is publishing endpoints. You can still follow along by creating a virtual endpoint of your own - see Virtual Endpoints below.
Using the CLI¶
Creating a MIDI Client¶
All MIDI operations require a client:
from coremusic.midi import MIDIClient
client = MIDIClient("My Application")
try:
print(f"Created MIDI client: {client.name}")
finally:
# Always dispose when done - this also disposes its ports and
# virtual endpoints
client.dispose()
Or use the context manager:
from coremusic.midi import MIDIClient
with MIDIClient("My Application") as client:
print(f"MIDI client active: {client.name}")
# Client is automatically disposed when exiting
How Long to Keep a Client¶
Keep the client for as long as your program may need MIDI, rather than creating one per operation:
import time
from coremusic.midi import MIDIClient, note_off, note_on
class Synth:
"""Holds its MIDI client open for the lifetime of the object.
MIDIServer exits a few seconds after its last client disconnects, and that
invalidates this process's connection to it for good. Creating a client
per note - or disposing the last one between pieces of work - risks every
later `MIDIClient(...)` failing with "Unknown error code -2" until the
program is restarted.
"""
def __init__(self, name="My Synth"):
self.client = MIDIClient(name)
self.port = self.client.create_output_port("Output")
self.destination = self.client.create_virtual_destination(f"{name} In")
def note(self, note, velocity=100, duration=0.2):
self.port.send_data(self.destination, note_on(note, velocity))
time.sleep(duration)
self.port.send_data(self.destination, note_off(note))
def close(self):
self.client.dispose()
synth = Synth()
try:
for note in (60, 64, 67):
synth.note(note)
# Idle time is fine: the client is still open, so the server stays up
time.sleep(0.5)
synth.note(72)
finally:
synth.close()
This matters more than it looks. MIDIServer is an on-demand system daemon:
it exits a few seconds after its last client disconnects, and that invalidates
this process's connection to CoreMIDI. The framework does not re-establish it,
so once it happens, every later MIDIClient(...) in the same process fails
with MIDIClientCreate failed: Unknown error code -2 no matter how long you
wait. Only restarting the process clears it.
A program that disposes its last client between pieces of work - a tool that idles between MIDI sessions, say - can therefore work perfectly on the first run through and fail on the second. Holding one client open avoids it entirely; the client costs nothing while idle and publishes no endpoints of its own.
Sending MIDI Messages¶
Creating an Output Port¶
from coremusic.midi import MIDIClient
def setup_midi_output():
"""Set up MIDI output."""
client = MIDIClient("MIDI Sender")
output_port = client.create_output_port("Output")
return client, output_port
client, port = setup_midi_output()
Choosing a Destination¶
send_data needs a destination endpoint. Pick one by index or by name:
from coremusic.midi import find_destination, get_destinations
destinations = get_destinations()
destination = destinations[0] if destinations else None
# Or search by name (exact match first, then case-insensitive substring)
iac = find_destination("IAC Driver")
Sending Note Messages¶
import time
from coremusic.midi import MIDIClient, get_destinations, note_off, note_on
def send_note(port, destination, note, velocity=100, duration=0.5, channel=0):
"""Send a note on/off pair."""
port.send_data(destination, note_on(note, velocity, channel=channel))
print(f"Note On: {note} velocity={velocity}")
time.sleep(duration)
port.send_data(destination, note_off(note, channel=channel))
print(f"Note Off: {note}")
# Send middle C
with MIDIClient("Note Sender") as client:
port = client.create_output_port("Output")
destinations = get_destinations()
if not destinations:
print("No MIDI destinations available")
else:
send_note(port, destinations[0], note=60, velocity=100, duration=0.5)
Sending Control Change¶
from coremusic.midi import MIDIClient, control_change, get_destinations
def send_cc(port, destination, controller, value, channel=0):
"""Send Control Change message."""
# CC message: 0xB0 + channel, controller number, value
port.send_data(destination, control_change(controller, value, channel=channel))
print(f"CC {controller}: {value}")
# Common CC numbers:
# CC 1 = Modulation wheel
# CC 7 = Volume
# CC 10 = Pan
# CC 64 = Sustain pedal
# CC 123 = All Notes Off
with MIDIClient("CC Sender") as client:
port = client.create_output_port("Output")
destinations = get_destinations()
if not destinations:
print("No MIDI destinations available")
else:
# Send modulation
send_cc(port, destinations[0], controller=1, value=64)
# Send volume
send_cc(port, destinations[0], controller=7, value=100)
Playing a Melody¶
import time
from coremusic.midi import MIDIClient, get_destinations, note_off, note_on
def play_melody(notes, durations, tempo_bpm=120):
"""Play a simple melody."""
with MIDIClient("Melody Player") as client:
port = client.create_output_port("Output")
destinations = get_destinations()
if not destinations:
print("No MIDI destinations available")
return
destination = destinations[0]
# Calculate beat duration
beat_duration = 60.0 / tempo_bpm
for note, duration in zip(notes, durations, strict=True):
port.send_data(destination, note_on(note, 100))
time.sleep(duration * beat_duration)
port.send_data(destination, note_off(note))
# The opening phrase of "Twinkle Twinkle Little Star"
notes = [60, 60, 67, 67, 69, 69, 67] # C C G G A A G
durations = [1, 1, 1, 1, 1, 1, 2]
play_melody(notes, durations, tempo_bpm=180)
Receiving MIDI Messages¶
Polling an Input Port¶
An input port created without a callback buffers incoming packets. Drain them
with poll(), optionally blocking on wait() first:
import time
from coremusic.midi import MIDIClient, get_sources
with MIDIClient("MIDI Receiver") as client:
input_port = client.create_input_port("Input")
# Connect to all sources
for source in get_sources():
input_port.connect_source(source)
# Listen for a second. A real program would loop until it is told to stop.
deadline = time.monotonic() + 1.0
while time.monotonic() < deadline:
if input_port.wait(0.1):
for host_time, data in input_port.poll():
print(host_time, data.hex())
poll() returns (host_time, data) tuples. Convert host_time to seconds with
capi.midi_host_time_to_seconds().
Using a Callback¶
Pass a callback to receive packets as they arrive instead. The callback runs on the CoreMIDI receive thread, so it must be short and must not block:
import time
from coremusic.midi import MIDIClient, get_sources
def midi_callback(data, host_time):
"""Callback for incoming MIDI data."""
print(f"Received {data.hex()} at {host_time}")
with MIDIClient("MIDI Receiver") as client:
input_port = client.create_input_port("Input", callback=midi_callback)
for source in get_sources():
input_port.connect_source(source)
# The callback fires on the CoreMIDI receive thread while we wait here.
time.sleep(1.0)
Splitting Packets Into Messages¶
One packet may hold several MIDI messages, and a SysEx message may span
packets. MIDIMessageSplitter keeps the state needed to separate them:
from coremusic.midi import MIDIMessageSplitter
splitter = MIDIMessageSplitter()
for _host_time, data in input_port.poll():
for message in splitter.push(data):
print(message.hex())
Use one splitter per source; it carries running-status and SysEx state across packets.
Simple MIDI Monitor¶
import time
from coremusic.midi import MIDIClient, MIDIMessageSplitter, get_sources
class MIDIMonitor:
"""Monitor and display incoming MIDI messages."""
def __init__(self):
self.client = MIDIClient("MIDI Monitor")
self.splitter = MIDIMessageSplitter()
def parse_message(self, data):
"""Parse MIDI message bytes."""
if len(data) == 0:
return None
status = data[0]
channel = status & 0x0F
msg_type = status & 0xF0
if msg_type == 0x90 and len(data) >= 3:
# Note On
note, velocity = data[1], data[2]
if velocity > 0:
return f"Note On ch={channel} note={note} vel={velocity}"
else:
return f"Note Off ch={channel} note={note}"
elif msg_type == 0x80 and len(data) >= 3:
# Note Off
note = data[1]
return f"Note Off ch={channel} note={note}"
elif msg_type == 0xB0 and len(data) >= 3:
# Control Change
cc, value = data[1], data[2]
return f"CC ch={channel} cc={cc} val={value}"
elif msg_type == 0xC0 and len(data) >= 2:
# Program Change
program = data[1]
return f"Program ch={channel} prog={program}"
elif msg_type == 0xE0 and len(data) >= 3:
# Pitch Bend
lsb, msb = data[1], data[2]
value = (msb << 7) | lsb
return f"PitchBnd ch={channel} val={value}"
else:
return f"Unknown {' '.join(f'{b:02X}' for b in data)}"
def run(self, seconds):
"""Print every message that arrives within `seconds`."""
input_port = self.client.create_input_port("Monitor Input")
sources = get_sources()
print(f"Monitoring {len(sources)} MIDI sources for {seconds}s...")
for source in sources:
input_port.connect_source(source)
deadline = time.monotonic() + seconds
try:
while time.monotonic() < deadline:
if not input_port.wait(0.1):
continue
for _host_time, data in input_port.poll():
for message in self.splitter.push(data):
text = self.parse_message(message)
if text:
print(text)
except KeyboardInterrupt:
print("\nStopping...")
self.client.dispose()
monitor = MIDIMonitor()
monitor.run(seconds=1.0)
Using the CLI¶
# Monitor MIDI input
coremusic midi monitor
# Display incoming MIDI as raw events
coremusic midi receive
Virtual Endpoints¶
A client can publish its own endpoints, which other applications then see in their MIDI device lists. This is also the easiest way to test send and receive code without any hardware.
A virtual destination receives what other applications send you:
import time
from coremusic.midi import MIDIClient, note_off, note_on
with MIDIClient("My Synth") as client:
# Other applications now see "My Synth Input" as a MIDI output
destination = client.create_virtual_destination("My Synth Input")
deadline = time.monotonic() + 1.0
while time.monotonic() < deadline:
if destination.wait(0.1):
for _host_time, data in destination.poll():
print(data.hex())
Like an input port, a virtual destination accepts a callback instead:
destination = client.create_virtual_destination(
"My Synth Input", callback=lambda data, host_time: print(data.hex())
)
A virtual source produces MIDI that other applications can subscribe to:
from coremusic.midi import MIDIClient, note_off, note_on
with MIDIClient("My Controller") as client:
# Other applications now see "My Controller Out" as a MIDI input
source = client.create_virtual_source("My Controller Out")
source.send(note_on("C4", 100))
source.send(note_off("C4"))
Both are disposed with the client. Endpoints returned by get_sources() and
get_destinations() belong to other processes, so disposing those wrappers
leaves the underlying endpoint alone.
Loopback Test¶
Putting both halves together gives a self-contained round trip:
from coremusic.midi import MIDIClient, note_on
with MIDIClient("Loopback") as client:
destination = client.create_virtual_destination("Loopback In")
port = client.create_output_port("Loopback Out")
port.send_data(destination, note_on("C4", 100))
assert destination.wait(1.0)
print(destination.poll())
MIDI Message Reference¶
Build messages with the functions in coremusic.midi rather than assembling
bytes by hand. Each returns the bytes that send_data takes, validates its
arguments, and gets the awkward parts right - the two-byte messages and the
pitch bend split.
Note Messages¶
from coremusic.midi import note_off, note_on
# A note may be a MIDI number, a name, or a Note
note_on(60, 100) # b"\x90\x3c\x64"
note_on("C4", 100) # the same message
note_on("F#3", 100) # sharps and flats both parse
# Channel is keyword-only, so it can never be mistaken for a note
note_on("C4", 100, channel=2) # b"\x92\x3c\x64"
note_off("C4") # b"\x80\x3c\x00", release velocity 0
note_off("C4", 64) # with an explicit release velocity
# Note numbers run 0-127. Middle C is 60 ("C4"), A440 is 69 ("A4").
Channel is keyword-only. capi.midi_note_on takes (channel, note, velocity)
and returns a tuple, so allowing a positional channel here would make
note_on(0, 60, 100) build a valid but completely different message. It raises
TypeError instead.
Octave numbering is scientific pitch notation, matching note_name_to_midi:
middle C is "C4" is 60. Ableton Live and Logic display that note as C3 and
Cakewalk as C5, so prefer the MIDI number when matching a DAW display.
Control Change¶
from coremusic.constants import MIDIControlChange
from coremusic.midi import all_notes_off, all_sound_off, control_change
control_change(MIDIControlChange.MODULATION, 64) # mod wheel to 50%
control_change(MIDIControlChange.VOLUME, 100)
control_change(MIDIControlChange.PAN, 64) # centred
control_change(MIDIControlChange.SUSTAIN_PEDAL, 127) # sustain down
control_change(MIDIControlChange.SUSTAIN_PEDAL, 0) # sustain up
# The two panic messages differ: All Notes Off releases held notes and lets
# their release tails ring, All Sound Off cuts the channel dead.
all_notes_off() # CC 123
all_sound_off() # CC 120
Program Change¶
from coremusic.midi import program_change
program_change(0) # b"\xc0\x00", program 0 (piano)
program_change(48) # program 48 (strings)
# Program Change carries one data byte, so the message is two bytes long
assert len(program_change(0)) == 2
Pitch Bend¶
from coremusic.midi import PITCH_BEND_CENTER, PITCH_BEND_MAX, pitch_bend
# Pitch bend is 14-bit, split across two 7-bit bytes, least significant first.
# pitch_bend() does the split for you.
pitch_bend(PITCH_BEND_CENTER) # 8192, no bend
pitch_bend(0) # fully down
pitch_bend(PITCH_BEND_MAX) # 16383, fully up
# Reassembling the halves gives the original value back
message = pitch_bend(12000)
assert message[1] | (message[2] << 7) == 12000
Aftertouch¶
from coremusic.midi import channel_aftertouch, poly_aftertouch
poly_aftertouch("C4", 64) # pressure on one held note
channel_aftertouch(64) # pressure on every held note, two bytes
Validation¶
from coremusic.midi import note_on as _note_on
# Out-of-range values raise rather than wrapping silently. A velocity of 200
# masked to 7 bits would become 72, and a data byte above 127 reads as a status
# byte, desynchronising everything after it.
try:
_note_on(60, 200)
except ValueError as e:
print(e) # velocity must be 0-127, got 200
Note that MIDIEvent.to_bytes() masks instead of raising, so a velocity of 200
silently becomes 72 there.
Not Interchangeable With capi.midi_*¶
The capi.midi_note_on family looks similar but serves a different target: it
returns a fixed (status, data1, data2) triple for
capi.music_device_midi_event(), the AudioUnit MusicDevice call, whose data2
is "0 if not needed". Program Change and Channel Aftertouch are two bytes on
the wire, so bytes(capi.midi_program_change(...)) appends a 0x00 that a
receiver reads as data for a running-status message. Use the builders above for
anything sent through CoreMIDI.
Complete Example: MIDI Keyboard¶
A simple MIDI keyboard using computer keys:
import sys
import termios
import time
import tty
from coremusic.midi import MIDIClient, get_destinations, note_off, note_on
class MIDIKeyboard:
"""Computer keyboard to MIDI converter."""
# Map computer keys to MIDI notes
KEY_MAP = {
'a': 60, # C4
'w': 61, # C#4
's': 62, # D4
'e': 63, # D#4
'd': 64, # E4
'f': 65, # F4
't': 66, # F#4
'g': 67, # G4
'y': 68, # G#4
'h': 69, # A4
'u': 70, # A#4
'j': 71, # B4
'k': 72, # C5
}
# A terminal reports key presses, not key releases, so each note is held
# for a fixed time rather than until the key comes back up.
NOTE_DURATION = 0.3
def __init__(self):
self.client = MIDIClient("MIDI Keyboard")
self.port = self.client.create_output_port("Output")
# Send to the first available destination, or publish our own so the
# keyboard is usable with no hardware attached.
destinations = get_destinations()
if destinations:
self.destination = destinations[0]
else:
self.destination = self.client.create_virtual_destination(
"MIDI Keyboard Out"
)
def play(self, note, velocity=100):
"""Play one note."""
self.port.send_data(self.destination, note_on(note, velocity))
print(f"Note On: {note}")
time.sleep(self.NOTE_DURATION)
self.port.send_data(self.destination, note_off(note))
def run(self):
"""Run keyboard input loop."""
print("MIDI Keyboard")
print("=" * 40)
print("Keys: A-S-D-F-G-H-J-K = C-D-E-F-G-A-B-C")
print("Black keys: W-E-T-Y-U")
print("Press 'q' to quit")
print()
# Set terminal to raw mode
old_settings = termios.tcgetattr(sys.stdin)
try:
tty.setraw(sys.stdin.fileno())
while True:
char = sys.stdin.read(1).lower()
if char == 'q':
break
if char in self.KEY_MAP:
self.play(self.KEY_MAP[char])
except KeyboardInterrupt:
pass
finally:
# Restore terminal
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
self.client.dispose()
print("\nGoodbye!")
if sys.stdin.isatty():
MIDIKeyboard().run()
else:
print("Not running on a terminal - nothing to read keys from.")
Troubleshooting¶
No MIDI Devices Found¶
- Check Audio MIDI Setup.app for device visibility
- Enable the IAC Driver in Audio MIDI Setup to get a software loopback bus
- Ensure MIDI devices are connected and powered on
- Try unplugging and reconnecting USB MIDI devices
- Check for driver requirements
Messages Not Received¶
- Verify source is connected to input port
- Check device is sending on expected channel
- Check
input_port.dropped- a non-zero value means the port is not being polled fast enough - Use MIDI Monitor to verify messages
Messages Not Sending¶
- Verify a destination exists -
get_destinations()returning an empty list is the most common cause - Check receiving device/software is listening
- Try sending to a different destination
MIDIClientCreate failed: Unknown error code -2¶
The process has lost its connection to MIDIServer, which exits a few seconds
after its last client disconnects. CoreMIDI does not reconnect, so every
subsequent client creation in that process fails the same way and no amount of
retrying helps.
Restart the process to recover, and to prevent it, keep one client open for as
long as MIDI might be needed - see How Long to Keep a
Client. Status -304 has the same cause.
Next Steps¶
- MIDI Transform - Transform and process MIDI
- MIDI Processing Cookbook - MIDI processing recipes
- Link Integration Cookbook - Sync MIDI with Ableton Link
See Also¶
- API Reference - Complete API reference
- CLI Guide - CLI MIDI commands