Python wrapper for SnoizeMIDISpy - capture outgoing MIDI on macOS
Project description
pyMIDIspy - Python MIDI Spy for macOS
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)
Overview
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
Requirements
- 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
Installation
From Source (Recommended)
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 .
From Wheel (if available)
pip install pyMIDIspy
Note: The wheel includes the pre-built SnoizeMIDISpy framework, so no Xcode is required.
Manual Build
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 build
Quick Start
Install the MIDI Spy Driver (First Time Only)
The 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.
Usage
Incoming MIDI (from sources)
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)
Outgoing MIDI (to destinations)
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)
Both directions
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)
Filtering Messages
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 = None
Available 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 |
API Reference
Functions
get_destinations() -> List[MIDIDestination]
Get a list of all MIDI destinations (outputs) available on the system.
get_destination_by_name(name: str) -> Optional[MIDIDestination]
Find a MIDI destination by name (case-insensitive, partial match supported).
get_sources() -> List[MIDISource]
Get a list of all MIDI sources (inputs) available on the system.
get_source_by_name(name: str) -> Optional[MIDISource]
Find a MIDI source by name (case-insensitive, partial match supported).
install_driver_if_necessary() -> Optional[str]
Install the MIDI spy driver (for outgoing capture only). Returns None on success.
Classes
MIDIInputClient
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)
MIDIOutputClient
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)
MessageFilter
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
)
MIDISource
Represents a MIDI source endpoint (input).
MIDIDestination
Represents a MIDI destination endpoint (output).
MIDIMessage
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)
Exceptions
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
How It Works
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.
Troubleshooting
"Could not find SnoizeMIDISpy.framework"
Make sure you've built the framework and either:
- Set
SNOIZE_MIDI_SPY_FRAMEWORKenvironment variable - Copied the framework to
/Library/Frameworks/or~/Library/Frameworks/
"MIDI spy driver is missing"
Call install_driver_if_necessary() to install the driver. You may need to restart your DAW or MIDI applications after installation.
No MIDI messages received
- 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
Technical Notes
Why PyObjC is required
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.
Publishing to PyPI
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>
License
BSD License - see the LICENSE file.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pymidispy-1.1.1.tar.gz.
File metadata
- Download URL: pymidispy-1.1.1.tar.gz
- Upload date:
- Size: 152.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
25276c65127914e24ef27201633fda8c59d082a6ac0b3c62ba800e4ed98ca7c7
|
|
| MD5 |
95c2350e2fa56a32e4c631f625ab20fb
|
|
| BLAKE2b-256 |
346aa81839c61c5d9a996e26125b57c775a3afef8a9036545e034e49b3fa68d8
|
File details
Details for the file pymidispy-1.1.1-cp314-cp314-macosx_10_13_universal2.whl.
File metadata
- Download URL: pymidispy-1.1.1-cp314-cp314-macosx_10_13_universal2.whl
- Upload date:
- Size: 153.2 kB
- Tags: CPython 3.14, macOS 10.13+ universal2 (ARM64, x86-64)
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.14.2
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
35cba881ef7954d50d2fa7f183837883f138da0a7feb41eef556124df47b35d7
|
|
| MD5 |
bc3b3a84e5f64b08a5e09d4e39a46f27
|
|
| BLAKE2b-256 |
be9f9d5dae55cb71af5bb9d0d0d71e3a04fac5d2f8c9b642605905741230d02e
|