A Python library for MIDI capture on macOS, providing both:
- Outgoing MIDI capture - Spy on MIDI being sent TO destinations (via SnoizeMIDISpy)
- Incoming MIDI capture - Receive MIDI FROM sources (via standard CoreMIDI)
This library enables Python applications to:
-
Capture outgoing MIDI (
MIDIOutputClient) - Capture what other applications are sending to MIDI outputs. This uses the SnoizeMIDISpy driver and is not possible with normal MIDI APIs. -
Receive incoming MIDI (
MIDIInputClient) - Standard MIDI input from sources like keyboards and controllers.
Use cases:
- Debugging MIDI communication between apps and hardware
- Recording/logging MIDI output from DAWs and other applications
- Building MIDI monitoring and analysis tools
- Capturing both input and output for complete MIDI logging
- macOS only - Uses macOS-specific CoreMIDI
- Python 3.8+
- Xcode - Required to build the SnoizeMIDISpy framework from source
- PyObjC (installed automatically) - Required for Objective-C block callbacks
Clone the repository with submodules and build:
git clone --recursive https://github.com/gramster/pyMIDIspy.git
cd pyMIDIspy
# Build the framework and install the package
./build.sh
# Or install in development mode
pip install -e .pip install pyMIDIspyNote: The wheel includes the pre-built SnoizeMIDISpy framework, so no Xcode is required.
If you need more control over the build process:
# 1. Clone with submodules
git clone --recursive https://github.com/gramster/pyMIDIspy.git
cd pyMIDIspy
# 2. Initialize submodules if you didn't use --recursive
git submodule update --init --recursive
# 3. Build using pip (this compiles the framework automatically)
pip install .
# Or build a wheel
python -m buildThe spy driver needs to be installed once to enable outgoing MIDI capture:
from pyMIDIspy import install_driver_if_necessary
# This installs the driver to ~/Library/Audio/MIDI Drivers/
error = install_driver_if_necessary()
if error:
print(f"Driver installation failed: {error}")
else:
print("Driver installed successfully!")Note: You may need to restart any running MIDI applications after driver installation.
from pyMIDIspy import MIDIInputClient, get_sources
def on_midi(messages, source_id):
for msg in messages:
print(f"Received: {msg.data.hex()}")
# List sources
for src in get_sources():
print(f" {src.name}")
# Receive MIDI from a source by name
with MIDIInputClient(callback=on_midi) as client:
client.connect_source_by_name("KeyStep") # case-insensitive, partial match
import time
while True:
time.sleep(0.1)from pyMIDIspy import MIDIOutputClient, get_destinations, install_driver_if_necessary
# Install the spy driver (first time only)
install_driver_if_necessary()
def on_midi(messages, dest_id):
for msg in messages:
print(f"Captured outgoing: {msg.data.hex()}")
# List destinations
for dest in get_destinations():
print(f" {dest.name}")
# Capture MIDI being sent to a destination by name
with MIDIOutputClient(callback=on_midi) as client:
client.connect_destination_by_name("XR18") # case-insensitive, partial match
import time
while True:
time.sleep(0.1)from pyMIDIspy import MIDIOutputClient, MIDIInputClient, get_sources, get_destinations
def on_incoming(messages, source_id):
for msg in messages:
print(f"IN: {msg.data.hex()}")
def on_outgoing(messages, dest_id):
for msg in messages:
print(f"OUT: {msg.data.hex()}")
# Create both clients
with MIDIInputClient(callback=on_incoming) as input_client, \
MIDIOutputClient(callback=on_outgoing) as output_client:
# Connect to all sources and destinations
for src in get_sources():
input_client.connect_source(src)
for dest in get_destinations():
output_client.connect_destination(dest)
import time
while True:
time.sleep(0.1)Use MessageFilter to filter MIDI messages before they reach your callback:
from pyMIDIspy import MIDIInputClient, MessageFilter
# Only receive note messages on channel 1
filter = MessageFilter(types=["note"], channels=[1])
client = MIDIInputClient(callback=on_midi, message_filter=filter)Common filtering patterns:
# Exclude timing clock and active sensing (common noise)
filter = MessageFilter(exclude_types=["timing_clock", "active_sensing"])
# Only note on/off messages
filter = MessageFilter(types=["note"])
# Only control change messages for specific controllers (mod wheel, volume, pan)
filter = MessageFilter(types=["control_change"], controllers=[1, 7, 10])
# Only messages on channels 1-4
filter = MessageFilter(channels=[1, 2, 3, 4])
# Combine: notes on channel 1, excluding note-off
filter = MessageFilter(types=["note_on"], channels=[1])Change filter at runtime:
client = MIDIInputClient(callback=on_midi)
client.connect_source(source)
# Later, add filtering
client.message_filter = MessageFilter(types=["note"])
# Remove filtering
client.message_filter = NoneAvailable message types for filtering:
| Type | Description |
|---|---|
"note_off" |
Note Off messages |
"note_on" |
Note On messages (velocity > 0) |
"note" |
Both Note On and Note Off |
"control_change" |
Control Change (CC) messages |
"program_change" |
Program Change messages |
"pitch_bend" |
Pitch Bend messages |
"poly_pressure" |
Polyphonic Aftertouch |
"channel_pressure" |
Channel Aftertouch |
"sysex" |
System Exclusive messages |
"timing_clock" |
MIDI Clock (0xF8) |
"transport" |
Start, Stop, Continue |
"active_sensing" |
Active Sensing (0xFE) |
"realtime" |
All realtime (clock, transport, active sensing) |
"channel" |
All channel voice messages |
"system" |
All system messages |
Get a list of all MIDI destinations (outputs) available on the system.
Find a MIDI destination by name (case-insensitive, partial match supported).
Get a list of all MIDI sources (inputs) available on the system.
Find a MIDI source by name (case-insensitive, partial match supported).
Install the MIDI spy driver (for outgoing capture only). Returns None on success.
Receives incoming MIDI from sources (standard CoreMIDI). No driver required.
client = MIDIInputClient(callback=my_callback, client_name="MyApp", message_filter=filter)Methods:
connect_source(source: MIDISource)- Start receiving from a sourceconnect_source_by_name(name: str)- Connect by name (case-insensitive, partial match)disconnect_source(source: MIDISource)- Stop receivingdisconnect_source_by_name(name: str)- Disconnect by namedisconnect_all()- Disconnect from all sourcesclose()- Release all resources
Properties:
connected_sources- List of currently connected sourcesmessage_filter- Get/set the MessageFilter (or None)
Captures outgoing MIDI sent to destinations. Requires the spy driver.
client = MIDIOutputClient(callback=my_callback, message_filter=filter)Methods:
connect_destination(destination: MIDIDestination)- Start capturing from a destinationconnect_destination_by_name(name: str)- Connect by name (case-insensitive, partial match)disconnect_destination(destination: MIDIDestination)- Stop capturingdisconnect_destination_by_name(name: str)- Disconnect by namedisconnect_all()- Disconnect from all destinationsclose()- Release all resources
Properties:
connected_destinations- List of currently connected destinationsmessage_filter- Get/set the MessageFilter (or None)
Filters MIDI messages by type, channel, or other criteria.
filter = MessageFilter(
types=["note", "control_change"], # Include only these types
exclude_types=["timing_clock"], # Exclude these types
channels=[1, 2], # Include only these channels (1-16)
exclude_channels=[10], # Exclude these channels
controllers=[1, 7, 10], # For CC: only these controller numbers
notes=[60, 62, 64], # For notes: only these note numbers
)Represents a MIDI source endpoint (input).
Represents a MIDI destination endpoint (output).
Represents a captured MIDI message.
Attributes:
timestamp: int- Host time when the message was sentdata: bytes- Raw MIDI bytes
Properties:
status- The status byte (or None)channel- The MIDI channel 0-15 (for channel messages)
MIDISpyError- Base exception classDriverMissingError- The MIDI spy driver is not installedDriverCommunicationError- Failed to communicate with the driverConnectionExistsError- Already connected to this destinationConnectionNotFoundError- Not connected to this destination
The SnoizeMIDISpy framework consists of two parts:
-
MIDI Driver (
MIDI Monitor.plugin) - Installed in~/Library/Audio/MIDI Drivers/. This is a CoreMIDI driver that intercepts MIDI data sent to destinations. -
Client Framework - Communicates with the driver via Mach messages to receive the captured MIDI data.
When you connect to a destination, the driver starts forwarding copies of all MIDI messages sent to that destination to your callback.
Make sure you've built the framework and either:
- Set
SNOIZE_MIDI_SPY_FRAMEWORKenvironment variable - Copied the framework to
/Library/Frameworks/or~/Library/Frameworks/
Call install_driver_if_necessary() to install the driver. You may need to restart your DAW or MIDI applications after installation.
- Make sure the driver is installed (for outgoing capture)
- Verify the endpoint exists with
get_destinations()orget_sources() - Check that MIDI is actually being sent/received
- The MIDI Monitor app from MIDIApps can help debug
CoreMIDI's MIDIReadBlock callback is an Objective-C block type:
void (^)(const MIDIPacketList *pktlist, void *srcConnRefCon)Blocks are not simple C function pointers—they're closures with a special memory layout that the runtime can retain/release. The SnoizeMIDISpy framework calls CFRetain() on the callback, which would crash with a plain C function pointer. PyObjC's objc.Block creates properly-structured blocks that are ABI-compatible with what CoreMIDI and the framework expect.
To publish a new version to PyPI:
# 1. Build the wheel (this compiles the SnoizeMIDISpy framework)
./build.sh
# 2. Verify the package metadata and contents
twine check dist/*
# 3. (Recommended) Test on TestPyPI first
twine upload --repository testpypi dist/*
pip install --index-url https://test.pypi.org/simple/ pyMIDIspy
# 4. Upload to PyPI
twine upload dist/*Notes:
- The wheel is macOS-only and tagged as
macosx_10_13_universal2(supports both arm64 and x86_64) - Source distributions require Xcode to build the framework
- You'll need a PyPI account and API token (create at https://pypi.org/manage/account/token/)
- Store your token in
~/.pypircor useTWINE_USERNAME=__token__andTWINE_PASSWORD=<your-token>
BSD License - see the LICENSE file.