Skip to content

gramster/pyMIDIspy

Repository files navigation

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:

  1. 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.

  2. 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 source
  • connect_source_by_name(name: str) - Connect by name (case-insensitive, partial match)
  • disconnect_source(source: MIDISource) - Stop receiving
  • disconnect_source_by_name(name: str) - Disconnect by name
  • disconnect_all() - Disconnect from all sources
  • close() - Release all resources

Properties:

  • connected_sources - List of currently connected sources
  • message_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 destination
  • connect_destination_by_name(name: str) - Connect by name (case-insensitive, partial match)
  • disconnect_destination(destination: MIDIDestination) - Stop capturing
  • disconnect_destination_by_name(name: str) - Disconnect by name
  • disconnect_all() - Disconnect from all destinations
  • close() - Release all resources

Properties:

  • connected_destinations - List of currently connected destinations
  • message_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 sent
  • data: bytes - Raw MIDI bytes

Properties:

  • status - The status byte (or None)
  • channel - The MIDI channel 0-15 (for channel messages)

Exceptions

  • MIDISpyError - Base exception class
  • DriverMissingError - The MIDI spy driver is not installed
  • DriverCommunicationError - Failed to communicate with the driver
  • ConnectionExistsError - Already connected to this destination
  • ConnectionNotFoundError - Not connected to this destination

How It Works

The SnoizeMIDISpy framework consists of two parts:

  1. MIDI Driver (MIDI Monitor.plugin) - Installed in ~/Library/Audio/MIDI Drivers/. This is a CoreMIDI driver that intercepts MIDI data sent to destinations.

  2. 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_FRAMEWORK environment 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() or get_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 ~/.pypirc or use TWINE_USERNAME=__token__ and TWINE_PASSWORD=<your-token>

License

BSD License - see the LICENSE file.

About

A Python library for capturing MIDI data on MacOS

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors