Skip to content

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:

  • MIDIClient owns everything else. Create one per application.
  • MIDIPort is this application's connection to the MIDI system. An output port sends, an input port receives.
  • MIDIEndpoint is 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

# List MIDI devices, inputs, and outputs
coremusic midi list

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

  1. Check Audio MIDI Setup.app for device visibility
  2. Enable the IAC Driver in Audio MIDI Setup to get a software loopback bus
  3. Ensure MIDI devices are connected and powered on
  4. Try unplugging and reconnecting USB MIDI devices
  5. Check for driver requirements

Messages Not Received

  1. Verify source is connected to input port
  2. Check device is sending on expected channel
  3. Check input_port.dropped - a non-zero value means the port is not being polled fast enough
  4. Use MIDI Monitor to verify messages

Messages Not Sending

  1. Verify a destination exists - get_destinations() returning an empty list is the most common cause
  2. Check receiving device/software is listening
  3. 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

See Also