pyMIDIspy 1.0.0__cp314-cp314-macosx_10_13_universal2.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,436 @@
1
+ Metadata-Version: 2.4
2
+ Name: pyMIDIspy
3
+ Version: 1.0.0
4
+ Summary: Python wrapper for SnoizeMIDISpy - capture outgoing MIDI on macOS
5
+ Home-page: https://github.com/gramster/pyMIDIspy
6
+ Author: gramster
7
+ License: BSD-3-Clause
8
+ Project-URL: Repository, https://github.com/gramster/pyMIDIspy
9
+ Project-URL: Homepage, https://github.com/gramster/pyMIDIspy
10
+ Keywords: midi,macos,coremidi,audio,music
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: BSD License
14
+ Classifier: Operating System :: MacOS :: MacOS X
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Multimedia :: Sound/Audio :: MIDI
23
+ Requires-Python: >=3.8
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: pyobjc-core
27
+ Requires-Dist: pyobjc-framework-Cocoa
28
+ Provides-Extra: dev
29
+ Requires-Dist: pytest; extra == "dev"
30
+ Requires-Dist: mypy; extra == "dev"
31
+ Requires-Dist: build; extra == "dev"
32
+ Requires-Dist: twine; extra == "dev"
33
+ Dynamic: home-page
34
+ Dynamic: license-file
35
+ Dynamic: requires-python
36
+
37
+ # pyMIDIspy - Python MIDI Spy for macOS
38
+
39
+ A Python library for MIDI capture on macOS, providing both:
40
+ - **Outgoing MIDI capture** - Spy on MIDI being sent TO destinations (via SnoizeMIDISpy)
41
+ - **Incoming MIDI capture** - Receive MIDI FROM sources (via standard CoreMIDI)
42
+
43
+ ## Overview
44
+
45
+ This library enables Python applications to:
46
+
47
+ 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.
48
+
49
+ 2. **Receive incoming MIDI** (`MIDIInputClient`) - Standard MIDI input from sources like keyboards and controllers.
50
+
51
+ Use cases:
52
+ - Debugging MIDI communication between apps and hardware
53
+ - Recording/logging MIDI output from DAWs and other applications
54
+ - Building MIDI monitoring and analysis tools
55
+ - Capturing both input and output for complete MIDI logging
56
+
57
+ ## Requirements
58
+
59
+ - **macOS only** - Uses macOS-specific CoreMIDI
60
+ - **Python 3.8+**
61
+ - **Xcode** - Required to build the SnoizeMIDISpy framework from source
62
+ - **PyObjC** (installed automatically) - Required for Objective-C block callbacks
63
+
64
+ ## Installation
65
+
66
+ ### From Source (Recommended)
67
+
68
+ Clone the repository with submodules and build:
69
+
70
+ ```bash
71
+ git clone --recursive https://github.com/gramster/pyMIDIspy.git
72
+ cd pyMIDIspy
73
+
74
+ # Build the framework and install the package
75
+ ./build.sh
76
+
77
+ # Or install in development mode
78
+ pip install -e .
79
+ ```
80
+
81
+ ### From Wheel (if available)
82
+
83
+ ```bash
84
+ pip install pyMIDIspy
85
+ ```
86
+
87
+ Note: The wheel includes the pre-built SnoizeMIDISpy framework, so no Xcode is required.
88
+
89
+ ### Manual Build
90
+
91
+ If you need more control over the build process:
92
+
93
+ ```bash
94
+ # 1. Clone with submodules
95
+ git clone --recursive https://github.com/gramster/pyMIDIspy.git
96
+ cd pyMIDIspy
97
+
98
+ # 2. Initialize submodules if you didn't use --recursive
99
+ git submodule update --init --recursive
100
+
101
+ # 3. Build using pip (this compiles the framework automatically)
102
+ pip install .
103
+
104
+ # Or build a wheel
105
+ python -m build
106
+ ```
107
+
108
+ ## Quick Start
109
+
110
+ ### Install the MIDI Spy Driver (First Time Only)
111
+
112
+ The spy driver needs to be installed once to enable outgoing MIDI capture:
113
+
114
+ ```python
115
+ from pyMIDIspy import install_driver_if_necessary
116
+
117
+ # This installs the driver to ~/Library/Audio/MIDI Drivers/
118
+ error = install_driver_if_necessary()
119
+ if error:
120
+ print(f"Driver installation failed: {error}")
121
+ else:
122
+ print("Driver installed successfully!")
123
+ ```
124
+
125
+ **Note:** You may need to restart any running MIDI applications after driver installation.
126
+
127
+ ## Usage
128
+
129
+ ### Incoming MIDI (from sources)
130
+
131
+ ```python
132
+ from pyMIDIspy import MIDIInputClient, get_sources
133
+
134
+ def on_midi(messages, source_id):
135
+ for msg in messages:
136
+ print(f"Received: {msg.data.hex()}")
137
+
138
+ # List sources
139
+ for src in get_sources():
140
+ print(f" {src.name} (ID: {src.unique_id})")
141
+
142
+ # Receive MIDI from a source
143
+ with MIDIInputClient(callback=on_midi) as client:
144
+ sources = get_sources()
145
+ if sources:
146
+ client.connect_source(sources[0])
147
+
148
+ import time
149
+ while True:
150
+ time.sleep(0.1)
151
+ ```
152
+
153
+ ### Outgoing MIDI (to destinations)
154
+
155
+ ```python
156
+ from pyMIDIspy import MIDIOutputClient, get_destinations, install_driver_if_necessary
157
+
158
+ # Install the spy driver (first time only)
159
+ install_driver_if_necessary()
160
+
161
+ def on_midi(messages, dest_id):
162
+ for msg in messages:
163
+ print(f"Captured outgoing: {msg.data.hex()}")
164
+
165
+ # Capture MIDI being sent to a destination
166
+ with MIDIOutputClient(callback=on_midi) as client:
167
+ destinations = get_destinations()
168
+ if destinations:
169
+ client.connect_destination(destinations[0])
170
+
171
+ import time
172
+ while True:
173
+ time.sleep(0.1)
174
+ ```
175
+
176
+ ### Both directions
177
+
178
+ ```python
179
+ from pyMIDIspy import MIDIOutputClient, MIDIInputClient, get_sources, get_destinations
180
+
181
+ def on_incoming(messages, source_id):
182
+ for msg in messages:
183
+ print(f"IN: {msg.data.hex()}")
184
+
185
+ def on_outgoing(messages, dest_id):
186
+ for msg in messages:
187
+ print(f"OUT: {msg.data.hex()}")
188
+
189
+ # Create both clients
190
+ with MIDIInputClient(callback=on_incoming) as input_client, \
191
+ MIDIOutputClient(callback=on_outgoing) as output_client:
192
+
193
+ # Connect to all sources and destinations
194
+ for src in get_sources():
195
+ input_client.connect_source(src)
196
+ for dest in get_destinations():
197
+ output_client.connect_destination(dest)
198
+
199
+ import time
200
+ while True:
201
+ time.sleep(0.1)
202
+ ```
203
+
204
+ ### Filtering Messages
205
+
206
+ Use `MessageFilter` to filter MIDI messages before they reach your callback:
207
+
208
+ ```python
209
+ from pyMIDIspy import MIDIInputClient, MessageFilter
210
+
211
+ # Only receive note messages on channel 1
212
+ filter = MessageFilter(types=["note"], channels=[1])
213
+
214
+ client = MIDIInputClient(callback=on_midi, message_filter=filter)
215
+ ```
216
+
217
+ **Common filtering patterns:**
218
+
219
+ ```python
220
+ # Exclude timing clock and active sensing (common noise)
221
+ filter = MessageFilter(exclude_types=["timing_clock", "active_sensing"])
222
+
223
+ # Only note on/off messages
224
+ filter = MessageFilter(types=["note"])
225
+
226
+ # Only control change messages for specific controllers (mod wheel, volume, pan)
227
+ filter = MessageFilter(types=["control_change"], controllers=[1, 7, 10])
228
+
229
+ # Only messages on channels 1-4
230
+ filter = MessageFilter(channels=[1, 2, 3, 4])
231
+
232
+ # Combine: notes on channel 1, excluding note-off
233
+ filter = MessageFilter(types=["note_on"], channels=[1])
234
+ ```
235
+
236
+ **Change filter at runtime:**
237
+
238
+ ```python
239
+ client = MIDIInputClient(callback=on_midi)
240
+ client.connect_source(source)
241
+
242
+ # Later, add filtering
243
+ client.message_filter = MessageFilter(types=["note"])
244
+
245
+ # Remove filtering
246
+ client.message_filter = None
247
+ ```
248
+
249
+ **Available message types for filtering:**
250
+
251
+ | Type | Description |
252
+ |------|-------------|
253
+ | `"note_off"` | Note Off messages |
254
+ | `"note_on"` | Note On messages (velocity > 0) |
255
+ | `"note"` | Both Note On and Note Off |
256
+ | `"control_change"` | Control Change (CC) messages |
257
+ | `"program_change"` | Program Change messages |
258
+ | `"pitch_bend"` | Pitch Bend messages |
259
+ | `"poly_pressure"` | Polyphonic Aftertouch |
260
+ | `"channel_pressure"` | Channel Aftertouch |
261
+ | `"sysex"` | System Exclusive messages |
262
+ | `"timing_clock"` | MIDI Clock (0xF8) |
263
+ | `"transport"` | Start, Stop, Continue |
264
+ | `"active_sensing"` | Active Sensing (0xFE) |
265
+ | `"realtime"` | All realtime (clock, transport, active sensing) |
266
+ | `"channel"` | All channel voice messages |
267
+ | `"system"` | All system messages |
268
+
269
+ ### API Reference
270
+
271
+ #### Functions
272
+
273
+ ##### `get_destinations() -> List[MIDIDestination]`
274
+ Get a list of all MIDI destinations (outputs) available on the system.
275
+
276
+ ##### `get_destination_by_unique_id(unique_id: int) -> Optional[MIDIDestination]`
277
+ Find a specific MIDI destination by its unique identifier.
278
+
279
+ ##### `get_sources() -> List[MIDISource]`
280
+ Get a list of all MIDI sources (inputs) available on the system.
281
+
282
+ ##### `get_source_by_unique_id(unique_id: int) -> Optional[MIDISource]`
283
+ Find a specific MIDI source by its unique identifier.
284
+
285
+ ##### `install_driver_if_necessary() -> Optional[str]`
286
+ Install the MIDI spy driver (for outgoing capture only). Returns `None` on success.
287
+
288
+ #### Classes
289
+
290
+ ##### `MIDIInputClient`
291
+
292
+ Receives incoming MIDI from sources (standard CoreMIDI). No driver required.
293
+
294
+ ```python
295
+ client = MIDIInputClient(callback=my_callback, client_name="MyApp", message_filter=filter)
296
+ ```
297
+
298
+ **Methods:**
299
+ - `connect_source(source: MIDISource)` - Start receiving from a source
300
+ - `connect_source_by_id(unique_id: int)` - Connect by unique ID
301
+ - `disconnect_source(source: MIDISource)` - Stop receiving
302
+ - `disconnect_all()` - Disconnect from all sources
303
+ - `close()` - Release all resources
304
+
305
+ **Properties:**
306
+ - `connected_sources` - List of currently connected sources
307
+ - `message_filter` - Get/set the MessageFilter (or None)
308
+
309
+ ##### `MIDIOutputClient`
310
+
311
+ Captures outgoing MIDI sent to destinations. Requires the spy driver.
312
+
313
+ ```python
314
+ client = MIDIOutputClient(callback=my_callback, message_filter=filter)
315
+ ```
316
+
317
+ **Methods:**
318
+ - `connect_destination(destination: MIDIDestination)` - Start capturing from a destination
319
+ - `connect_destination_by_id(unique_id: int)` - Connect by unique ID
320
+ - `disconnect_destination(destination: MIDIDestination)` - Stop capturing
321
+ - `disconnect_destination_by_id(unique_id: int)` - Disconnect by unique ID
322
+ - `disconnect_all()` - Disconnect from all destinations
323
+ - `close()` - Release all resources
324
+
325
+ **Properties:**
326
+ - `connected_destinations` - List of currently connected destinations
327
+ - `message_filter` - Get/set the MessageFilter (or None)
328
+
329
+ ##### `MessageFilter`
330
+
331
+ Filters MIDI messages by type, channel, or other criteria.
332
+
333
+ ```python
334
+ filter = MessageFilter(
335
+ types=["note", "control_change"], # Include only these types
336
+ exclude_types=["timing_clock"], # Exclude these types
337
+ channels=[1, 2], # Include only these channels (1-16)
338
+ exclude_channels=[10], # Exclude these channels
339
+ controllers=[1, 7, 10], # For CC: only these controller numbers
340
+ notes=[60, 62, 64], # For notes: only these note numbers
341
+ )
342
+ ```
343
+
344
+ ##### `MIDISource`
345
+
346
+ Represents a MIDI source endpoint (input).
347
+
348
+ ##### `MIDIDestination`
349
+
350
+ Represents a MIDI destination endpoint (output).
351
+
352
+ ##### `MIDIMessage`
353
+
354
+ Represents a captured MIDI message.
355
+
356
+ **Attributes:**
357
+ - `timestamp: int` - Host time when the message was sent
358
+ - `data: bytes` - Raw MIDI bytes
359
+
360
+ **Properties:**
361
+ - `status` - The status byte (or None)
362
+ - `channel` - The MIDI channel 0-15 (for channel messages)
363
+
364
+ #### Exceptions
365
+
366
+ - `MIDISpyError` - Base exception class
367
+ - `DriverMissingError` - The MIDI spy driver is not installed
368
+ - `DriverCommunicationError` - Failed to communicate with the driver
369
+ - `ConnectionExistsError` - Already connected to this destination
370
+ - `ConnectionNotFoundError` - Not connected to this destination
371
+
372
+ ## How It Works
373
+
374
+ The SnoizeMIDISpy framework consists of two parts:
375
+
376
+ 1. **MIDI Driver** (`MIDI Monitor.plugin`) - Installed in `~/Library/Audio/MIDI Drivers/`. This is a CoreMIDI driver that intercepts MIDI data sent to destinations.
377
+
378
+ 2. **Client Framework** - Communicates with the driver via Mach messages to receive the captured MIDI data.
379
+
380
+ When you connect to a destination, the driver starts forwarding copies of all MIDI messages sent to that destination to your callback.
381
+
382
+ ## Troubleshooting
383
+
384
+ ### "Could not find SnoizeMIDISpy.framework"
385
+ Make sure you've built the framework and either:
386
+ - Set `SNOIZE_MIDI_SPY_FRAMEWORK` environment variable
387
+ - Copied the framework to `/Library/Frameworks/` or `~/Library/Frameworks/`
388
+
389
+ ### "MIDI spy driver is missing"
390
+ Call `install_driver_if_necessary()` to install the driver. You may need to restart your DAW or MIDI applications after installation.
391
+
392
+ ### No MIDI messages received
393
+ - Make sure the driver is installed (for outgoing capture)
394
+ - Verify the endpoint exists with `get_destinations()` or `get_sources()`
395
+ - Check that MIDI is actually being sent/received
396
+ - The MIDI Monitor app from MIDIApps can help debug
397
+
398
+ ## Technical Notes
399
+
400
+ ### Why PyObjC is required
401
+
402
+ CoreMIDI's `MIDIReadBlock` callback is an Objective-C block type:
403
+ ```c
404
+ void (^)(const MIDIPacketList *pktlist, void *srcConnRefCon)
405
+ ```
406
+
407
+ 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.
408
+
409
+ ## Publishing to PyPI
410
+
411
+ To publish a new version to PyPI:
412
+
413
+ ```bash
414
+ # 1. Build the wheel (this compiles the SnoizeMIDISpy framework)
415
+ ./build.sh
416
+
417
+ # 2. Verify the package metadata and contents
418
+ twine check dist/*
419
+
420
+ # 3. (Recommended) Test on TestPyPI first
421
+ twine upload --repository testpypi dist/*
422
+ pip install --index-url https://test.pypi.org/simple/ pyMIDIspy
423
+
424
+ # 4. Upload to PyPI
425
+ twine upload dist/*
426
+ ```
427
+
428
+ **Notes:**
429
+ - The wheel is macOS-only and tagged as `macosx_10_13_universal2` (supports both arm64 and x86_64)
430
+ - Source distributions require Xcode to build the framework
431
+ - You'll need a PyPI account and API token (create at https://pypi.org/manage/account/token/)
432
+ - Store your token in `~/.pypirc` or use `TWINE_USERNAME=__token__` and `TWINE_PASSWORD=<your-token>`
433
+
434
+ ## License
435
+
436
+ BSD License - see the LICENSE file.
@@ -0,0 +1,8 @@
1
+ pymidispy-1.0.0.data/purelib/pyMIDIspy/__init__.py,sha256=690ac3uHPf26F8jK3c5JcZmH_w4Dv5p2xv6v02Lb1sY,3858
2
+ pymidispy-1.0.0.data/purelib/pyMIDIspy/core.py,sha256=wmltYpceTqUe5dUsbVvS4zJRhm2lnNv6pBH0QeTjRU4,40631
3
+ pymidispy-1.0.0.data/purelib/pyMIDIspy/midi_utils.py,sha256=dF_Gis2jrmX1QUWgHzdFjpDvoLJrWKYi6qm5MnEbC04,14345
4
+ pymidispy-1.0.0.dist-info/licenses/LICENSE,sha256=4yQhIhnuGo-EYYXInCICmz15ZATx_J2MRz7HRyIfn_o,1493
5
+ pymidispy-1.0.0.dist-info/METADATA,sha256=6QxBDF0D0yQAQ0s3dERjeT89If7DQ38qFGK2q6gDfh4,13570
6
+ pymidispy-1.0.0.dist-info/WHEEL,sha256=k270JeYTEi7JZ22OBvUCY4w-JbllBANjHcahvUbWqko,116
7
+ pymidispy-1.0.0.dist-info/top_level.txt,sha256=3d4Gvqdgt9cIf__y4ODCvrYaKx8dWeAJhsxMoD6f13A,10
8
+ pymidispy-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.10.2)
3
+ Root-Is-Purelib: false
4
+ Tag: cp314-cp314-macosx_10_13_universal2
5
+
@@ -0,0 +1,27 @@
1
+ Copyright (c) 2001-2023, Kurt Revis
2
+ All rights reserved.
3
+
4
+ Redistribution and use in source and binary forms, with or without
5
+ modification, are permitted provided that the following conditions are met:
6
+
7
+ * Redistributions of source code must retain the above copyright notice, this
8
+ list of conditions and the following disclaimer.
9
+
10
+ * Redistributions in binary form must reproduce the above copyright notice,
11
+ this list of conditions and the following disclaimer in the documentation
12
+ and/or other materials provided with the distribution.
13
+
14
+ * Neither the name of the copyright holder nor the names of its
15
+ contributors may be used to endorse or promote products derived from
16
+ this software without specific prior written permission.
17
+
18
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
19
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
20
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
22
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
23
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
24
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
25
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
26
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
27
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1 @@
1
+ pyMIDIspy