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.
- pymidispy-1.0.0.data/purelib/pyMIDIspy/__init__.py +168 -0
- pymidispy-1.0.0.data/purelib/pyMIDIspy/core.py +1205 -0
- pymidispy-1.0.0.data/purelib/pyMIDIspy/midi_utils.py +430 -0
- pymidispy-1.0.0.dist-info/METADATA +436 -0
- pymidispy-1.0.0.dist-info/RECORD +8 -0
- pymidispy-1.0.0.dist-info/WHEEL +5 -0
- pymidispy-1.0.0.dist-info/licenses/LICENSE +27 -0
- pymidispy-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1205 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core implementation of the SnoizeMIDISpy Python wrapper.
|
|
3
|
+
|
|
4
|
+
This module provides bindings to:
|
|
5
|
+
1. SnoizeMIDISpy framework - for capturing OUTGOING MIDI (sent to destinations)
|
|
6
|
+
2. CoreMIDI - for capturing INCOMING MIDI (received from sources)
|
|
7
|
+
|
|
8
|
+
Requires PyObjC for Objective-C block support (MIDIReadBlock callbacks).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import ctypes
|
|
12
|
+
from ctypes import (
|
|
13
|
+
POINTER,
|
|
14
|
+
CFUNCTYPE,
|
|
15
|
+
c_void_p,
|
|
16
|
+
c_int32,
|
|
17
|
+
c_uint32,
|
|
18
|
+
c_uint16,
|
|
19
|
+
c_uint64,
|
|
20
|
+
c_uint8,
|
|
21
|
+
c_char_p,
|
|
22
|
+
byref,
|
|
23
|
+
Structure,
|
|
24
|
+
cast,
|
|
25
|
+
)
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Callable, List, Optional, Set
|
|
28
|
+
import threading
|
|
29
|
+
import os
|
|
30
|
+
|
|
31
|
+
# PyObjC is required for Objective-C block support
|
|
32
|
+
import objc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# =============================================================================
|
|
36
|
+
# Error codes and exceptions
|
|
37
|
+
# =============================================================================
|
|
38
|
+
|
|
39
|
+
class MIDISpyError(Exception):
|
|
40
|
+
"""Base exception for MIDISpy errors."""
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class DriverMissingError(MIDISpyError):
|
|
45
|
+
"""The MIDI spy driver is not installed."""
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class DriverCommunicationError(MIDISpyError):
|
|
50
|
+
"""Failed to communicate with the MIDI spy driver."""
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ConnectionExistsError(MIDISpyError):
|
|
55
|
+
"""A connection to this destination already exists."""
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ConnectionNotFoundError(MIDISpyError):
|
|
60
|
+
"""No connection exists to this destination."""
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Error code constants from MIDISpyClient.h
|
|
65
|
+
_kMIDISpyDriverMissing = 1
|
|
66
|
+
_kMIDISpyDriverCouldNotCommunicate = 2
|
|
67
|
+
_kMIDISpyConnectionAlreadyExists = 3
|
|
68
|
+
_kMIDISpyConnectionDoesNotExist = 4
|
|
69
|
+
|
|
70
|
+
_ERROR_MAP = {
|
|
71
|
+
_kMIDISpyDriverMissing: DriverMissingError,
|
|
72
|
+
_kMIDISpyDriverCouldNotCommunicate: DriverCommunicationError,
|
|
73
|
+
_kMIDISpyConnectionAlreadyExists: ConnectionExistsError,
|
|
74
|
+
_kMIDISpyConnectionDoesNotExist: ConnectionNotFoundError,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _check_status(status: int, operation: str = "operation"):
|
|
79
|
+
"""Check OSStatus and raise appropriate exception if non-zero."""
|
|
80
|
+
if status == 0:
|
|
81
|
+
return
|
|
82
|
+
|
|
83
|
+
exc_class = _ERROR_MAP.get(status, MIDISpyError)
|
|
84
|
+
raise exc_class(f"{operation} failed with status {status}")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# =============================================================================
|
|
88
|
+
# Data structures
|
|
89
|
+
# =============================================================================
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class MIDIMessage:
|
|
93
|
+
"""Represents a single MIDI message."""
|
|
94
|
+
timestamp: int # MIDITimeStamp (UInt64) in host time units
|
|
95
|
+
data: bytes # The raw MIDI bytes
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def status(self) -> Optional[int]:
|
|
99
|
+
"""Get the status byte if present."""
|
|
100
|
+
return self.data[0] if self.data else None
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def channel(self) -> Optional[int]:
|
|
104
|
+
"""Get the MIDI channel (0-15) if this is a channel message."""
|
|
105
|
+
if self.data and (self.data[0] & 0xF0) in range(0x80, 0xF0):
|
|
106
|
+
return self.data[0] & 0x0F
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
def __repr__(self):
|
|
110
|
+
hex_data = " ".join(f"{b:02X}" for b in self.data)
|
|
111
|
+
return f"MIDIMessage(timestamp={self.timestamp}, data=[{hex_data}])"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass
|
|
115
|
+
class MIDIDestination:
|
|
116
|
+
"""Represents a MIDI destination endpoint (output)."""
|
|
117
|
+
endpoint_ref: int # MIDIEndpointRef
|
|
118
|
+
unique_id: int # Unique identifier
|
|
119
|
+
name: str # Display name
|
|
120
|
+
|
|
121
|
+
def __hash__(self):
|
|
122
|
+
return hash(self.unique_id)
|
|
123
|
+
|
|
124
|
+
def __eq__(self, other):
|
|
125
|
+
if isinstance(other, MIDIDestination):
|
|
126
|
+
return self.unique_id == other.unique_id
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class MIDISource:
|
|
132
|
+
"""Represents a MIDI source endpoint (input)."""
|
|
133
|
+
endpoint_ref: int # MIDIEndpointRef
|
|
134
|
+
unique_id: int # Unique identifier
|
|
135
|
+
name: str # Display name
|
|
136
|
+
|
|
137
|
+
def __hash__(self):
|
|
138
|
+
return hash(self.unique_id)
|
|
139
|
+
|
|
140
|
+
def __eq__(self, other):
|
|
141
|
+
if isinstance(other, MIDISource):
|
|
142
|
+
return self.unique_id == other.unique_id
|
|
143
|
+
return False
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# =============================================================================
|
|
147
|
+
# MIDI Packet structures for parsing
|
|
148
|
+
# =============================================================================
|
|
149
|
+
|
|
150
|
+
class MIDIPacket(Structure):
|
|
151
|
+
"""
|
|
152
|
+
MIDIPacket structure:
|
|
153
|
+
MIDITimeStamp timeStamp (UInt64)
|
|
154
|
+
UInt16 length
|
|
155
|
+
Byte data[256] (variable length in practice)
|
|
156
|
+
"""
|
|
157
|
+
_pack_ = 1 # Byte-aligned initially, but MIDIPacketNext handles actual alignment
|
|
158
|
+
_fields_ = [
|
|
159
|
+
("timeStamp", c_uint64),
|
|
160
|
+
("length", c_uint16),
|
|
161
|
+
# data follows, variable length
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class MIDIPacketList(Structure):
|
|
166
|
+
"""
|
|
167
|
+
MIDIPacketList structure:
|
|
168
|
+
UInt32 numPackets
|
|
169
|
+
MIDIPacket packet[1] (variable length)
|
|
170
|
+
"""
|
|
171
|
+
_fields_ = [
|
|
172
|
+
("numPackets", c_uint32),
|
|
173
|
+
# packets follow
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _parse_midi_packet_list(data_ptr: c_void_p, data_length: int) -> List[MIDIMessage]:
|
|
178
|
+
"""
|
|
179
|
+
Parse a MIDIPacketList from raw data.
|
|
180
|
+
|
|
181
|
+
The data starts with:
|
|
182
|
+
SInt32 endpointUniqueID
|
|
183
|
+
MIDIPacketList packetList
|
|
184
|
+
"""
|
|
185
|
+
messages = []
|
|
186
|
+
|
|
187
|
+
if data_length < 4 + 4: # sizeof(SInt32) + sizeof(numPackets)
|
|
188
|
+
return messages
|
|
189
|
+
|
|
190
|
+
# Read as bytes
|
|
191
|
+
data = (c_uint8 * data_length).from_address(data_ptr)
|
|
192
|
+
|
|
193
|
+
# Skip the endpointUniqueID (4 bytes)
|
|
194
|
+
offset = 4
|
|
195
|
+
|
|
196
|
+
# Read numPackets
|
|
197
|
+
num_packets = int.from_bytes(bytes(data[offset:offset+4]), 'little')
|
|
198
|
+
offset += 4
|
|
199
|
+
|
|
200
|
+
for _ in range(num_packets):
|
|
201
|
+
if offset + 10 > data_length: # Need at least timestamp (8) + length (2)
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
# Read timestamp (8 bytes, little-endian)
|
|
205
|
+
timestamp = int.from_bytes(bytes(data[offset:offset+8]), 'little')
|
|
206
|
+
offset += 8
|
|
207
|
+
|
|
208
|
+
# Read length (2 bytes, little-endian)
|
|
209
|
+
length = int.from_bytes(bytes(data[offset:offset+2]), 'little')
|
|
210
|
+
offset += 2
|
|
211
|
+
|
|
212
|
+
if offset + length > data_length:
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
# Read MIDI data
|
|
216
|
+
midi_data = bytes(data[offset:offset+length])
|
|
217
|
+
offset += length
|
|
218
|
+
|
|
219
|
+
# Handle alignment for next packet (4-byte alignment on ARM)
|
|
220
|
+
# MIDIPacketNext accounts for this
|
|
221
|
+
remainder = offset % 4
|
|
222
|
+
if remainder != 0:
|
|
223
|
+
offset += 4 - remainder
|
|
224
|
+
|
|
225
|
+
messages.append(MIDIMessage(timestamp=timestamp, data=midi_data))
|
|
226
|
+
|
|
227
|
+
return messages
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# =============================================================================
|
|
231
|
+
# CoreMIDI types and loading
|
|
232
|
+
# =============================================================================
|
|
233
|
+
|
|
234
|
+
# Type aliases
|
|
235
|
+
MIDIClientRef = c_uint32
|
|
236
|
+
MIDIEndpointRef = c_uint32
|
|
237
|
+
MIDISpyClientRef = c_void_p
|
|
238
|
+
MIDISpyPortRef = c_void_p
|
|
239
|
+
|
|
240
|
+
# MIDIReadBlock callback type: void (^)(const MIDIPacketList *pktlist, void *srcConnRefCon)
|
|
241
|
+
# This is an Objective-C block. We'll handle it differently based on PyObjC availability.
|
|
242
|
+
MIDIReadBlockFunc = CFUNCTYPE(None, c_void_p, c_void_p)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _create_midi_read_block(callback_func):
|
|
246
|
+
"""
|
|
247
|
+
Create a MIDIReadBlock-compatible Objective-C block.
|
|
248
|
+
|
|
249
|
+
MIDIReadBlock is an Objective-C block with signature:
|
|
250
|
+
void (^)(const MIDIPacketList *pktlist, void *srcConnRefCon)
|
|
251
|
+
|
|
252
|
+
We use PyObjC to create a proper block that CoreMIDI can retain/release.
|
|
253
|
+
"""
|
|
254
|
+
# Block signature: void, pointer (packet list), pointer (refcon)
|
|
255
|
+
# Using 'v' for void, '@?' for block, '^v' for void pointer
|
|
256
|
+
block = objc.Block(callback_func, signature=b'v^v^v', argcount=2)
|
|
257
|
+
return block
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
# =============================================================================
|
|
261
|
+
# Framework loading
|
|
262
|
+
# =============================================================================
|
|
263
|
+
|
|
264
|
+
def _find_framework():
|
|
265
|
+
"""Find the SnoizeMIDISpy framework."""
|
|
266
|
+
# Get the package directory
|
|
267
|
+
package_dir = os.path.dirname(__file__)
|
|
268
|
+
|
|
269
|
+
# Possible locations for the framework
|
|
270
|
+
possible_paths = [
|
|
271
|
+
# Bundled in the package (primary location for installed package)
|
|
272
|
+
os.path.join(package_dir, "lib", "SnoizeMIDISpy.framework", "SnoizeMIDISpy"),
|
|
273
|
+
# Standard framework locations
|
|
274
|
+
"/Library/Frameworks/SnoizeMIDISpy.framework/SnoizeMIDISpy",
|
|
275
|
+
os.path.expanduser("~/Library/Frameworks/SnoizeMIDISpy.framework/SnoizeMIDISpy"),
|
|
276
|
+
# Development: vendor build directory
|
|
277
|
+
os.path.join(os.path.dirname(package_dir), "_build", "DerivedData", "Build", "Products", "Release", "SnoizeMIDISpy.framework", "SnoizeMIDISpy"),
|
|
278
|
+
# Development: MIDIApps build directory
|
|
279
|
+
os.path.join(os.path.dirname(package_dir), "vendor", "MIDIApps", "build", "Release", "SnoizeMIDISpy.framework", "SnoizeMIDISpy"),
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
# Also check SNOIZE_MIDI_SPY_FRAMEWORK environment variable
|
|
283
|
+
env_path = os.environ.get("SNOIZE_MIDI_SPY_FRAMEWORK")
|
|
284
|
+
if env_path:
|
|
285
|
+
possible_paths.insert(0, env_path)
|
|
286
|
+
|
|
287
|
+
for path in possible_paths:
|
|
288
|
+
if os.path.exists(path):
|
|
289
|
+
return path
|
|
290
|
+
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _load_coremidi():
|
|
295
|
+
"""Load CoreMIDI framework."""
|
|
296
|
+
return ctypes.CDLL("/System/Library/Frameworks/CoreMIDI.framework/CoreMIDI")
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _load_spy_framework(framework_path: Optional[str] = None):
|
|
300
|
+
"""Load the SnoizeMIDISpy framework."""
|
|
301
|
+
if framework_path is None:
|
|
302
|
+
framework_path = _find_framework()
|
|
303
|
+
|
|
304
|
+
if framework_path is None:
|
|
305
|
+
raise DriverMissingError(
|
|
306
|
+
"Could not find SnoizeMIDISpy.framework. "
|
|
307
|
+
"Please build the framework or set SNOIZE_MIDI_SPY_FRAMEWORK environment variable."
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
return ctypes.CDLL(framework_path)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# Global framework handles (lazy loaded)
|
|
314
|
+
_coremidi = None
|
|
315
|
+
_spy_framework = None
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _get_coremidi():
|
|
319
|
+
"""Get the CoreMIDI framework handle."""
|
|
320
|
+
global _coremidi
|
|
321
|
+
if _coremidi is None:
|
|
322
|
+
_coremidi = _load_coremidi()
|
|
323
|
+
return _coremidi
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _get_spy_framework():
|
|
327
|
+
"""Get the SnoizeMIDISpy framework handle."""
|
|
328
|
+
global _spy_framework
|
|
329
|
+
if _spy_framework is None:
|
|
330
|
+
_spy_framework = _load_spy_framework()
|
|
331
|
+
return _spy_framework
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# =============================================================================
|
|
335
|
+
# CoreMIDI helper functions
|
|
336
|
+
# =============================================================================
|
|
337
|
+
|
|
338
|
+
def get_destinations() -> List[MIDIDestination]:
|
|
339
|
+
"""
|
|
340
|
+
Get a list of all MIDI destinations in the system.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
List of MIDIDestination objects representing available MIDI outputs.
|
|
344
|
+
"""
|
|
345
|
+
coremidi = _get_coremidi()
|
|
346
|
+
|
|
347
|
+
# Set up function signatures
|
|
348
|
+
coremidi.MIDIGetNumberOfDestinations.argtypes = []
|
|
349
|
+
coremidi.MIDIGetNumberOfDestinations.restype = c_uint32
|
|
350
|
+
|
|
351
|
+
coremidi.MIDIGetDestination.argtypes = [c_uint32]
|
|
352
|
+
coremidi.MIDIGetDestination.restype = MIDIEndpointRef
|
|
353
|
+
|
|
354
|
+
# Load CoreFoundation for string handling
|
|
355
|
+
cf = ctypes.CDLL("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")
|
|
356
|
+
|
|
357
|
+
destinations = []
|
|
358
|
+
num_destinations = coremidi.MIDIGetNumberOfDestinations()
|
|
359
|
+
|
|
360
|
+
for i in range(num_destinations):
|
|
361
|
+
endpoint_ref = coremidi.MIDIGetDestination(i)
|
|
362
|
+
if endpoint_ref == 0:
|
|
363
|
+
continue
|
|
364
|
+
|
|
365
|
+
# Get unique ID
|
|
366
|
+
unique_id = c_int32()
|
|
367
|
+
status = coremidi.MIDIObjectGetIntegerProperty(
|
|
368
|
+
endpoint_ref,
|
|
369
|
+
cf.CFStringCreateWithCString(None, b"uniqueID", 0), # kMIDIPropertyUniqueID
|
|
370
|
+
byref(unique_id)
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
# Get display name
|
|
374
|
+
name = _get_endpoint_display_name(coremidi, cf, endpoint_ref)
|
|
375
|
+
|
|
376
|
+
destinations.append(MIDIDestination(
|
|
377
|
+
endpoint_ref=endpoint_ref,
|
|
378
|
+
unique_id=unique_id.value,
|
|
379
|
+
name=name
|
|
380
|
+
))
|
|
381
|
+
|
|
382
|
+
return destinations
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _get_endpoint_display_name(coremidi, cf, endpoint_ref: MIDIEndpointRef) -> str:
|
|
386
|
+
"""Get the display name of a MIDI endpoint."""
|
|
387
|
+
# Try to get the display name property
|
|
388
|
+
cf_string_ptr = c_void_p()
|
|
389
|
+
|
|
390
|
+
# Create the property name CFString
|
|
391
|
+
prop_name = cf.CFStringCreateWithCString(None, b"displayName", 0)
|
|
392
|
+
|
|
393
|
+
status = coremidi.MIDIObjectGetStringProperty(
|
|
394
|
+
endpoint_ref,
|
|
395
|
+
prop_name,
|
|
396
|
+
byref(cf_string_ptr)
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
if status != 0 or cf_string_ptr.value is None:
|
|
400
|
+
# Fall back to regular name
|
|
401
|
+
prop_name = cf.CFStringCreateWithCString(None, b"name", 0)
|
|
402
|
+
status = coremidi.MIDIObjectGetStringProperty(
|
|
403
|
+
endpoint_ref,
|
|
404
|
+
prop_name,
|
|
405
|
+
byref(cf_string_ptr)
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
if status != 0 or cf_string_ptr.value is None:
|
|
409
|
+
return f"Unknown Endpoint {endpoint_ref}"
|
|
410
|
+
|
|
411
|
+
# Convert CFString to Python string
|
|
412
|
+
cf.CFStringGetCString.argtypes = [c_void_p, c_char_p, c_int32, c_uint32]
|
|
413
|
+
cf.CFStringGetCString.restype = ctypes.c_bool
|
|
414
|
+
|
|
415
|
+
buffer = ctypes.create_string_buffer(256)
|
|
416
|
+
success = cf.CFStringGetCString(cf_string_ptr.value, buffer, 256, 0x08000100) # kCFStringEncodingUTF8
|
|
417
|
+
|
|
418
|
+
cf.CFRelease(cf_string_ptr)
|
|
419
|
+
|
|
420
|
+
if success:
|
|
421
|
+
return buffer.value.decode('utf-8')
|
|
422
|
+
return f"Unknown Endpoint {endpoint_ref}"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def get_destination_by_unique_id(unique_id: int) -> Optional[MIDIDestination]:
|
|
426
|
+
"""
|
|
427
|
+
Find a MIDI destination by its unique ID.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
unique_id: The unique identifier of the destination.
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
MIDIDestination if found, None otherwise.
|
|
434
|
+
"""
|
|
435
|
+
for dest in get_destinations():
|
|
436
|
+
if dest.unique_id == unique_id:
|
|
437
|
+
return dest
|
|
438
|
+
return None
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def get_sources() -> List[MIDISource]:
|
|
442
|
+
"""
|
|
443
|
+
Get a list of all MIDI sources in the system.
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
List of MIDISource objects representing available MIDI inputs.
|
|
447
|
+
"""
|
|
448
|
+
coremidi = _get_coremidi()
|
|
449
|
+
|
|
450
|
+
# Set up function signatures
|
|
451
|
+
coremidi.MIDIGetNumberOfSources.argtypes = []
|
|
452
|
+
coremidi.MIDIGetNumberOfSources.restype = c_uint32
|
|
453
|
+
|
|
454
|
+
coremidi.MIDIGetSource.argtypes = [c_uint32]
|
|
455
|
+
coremidi.MIDIGetSource.restype = MIDIEndpointRef
|
|
456
|
+
|
|
457
|
+
# Load CoreFoundation for string handling
|
|
458
|
+
cf = ctypes.CDLL("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")
|
|
459
|
+
|
|
460
|
+
sources = []
|
|
461
|
+
num_sources = coremidi.MIDIGetNumberOfSources()
|
|
462
|
+
|
|
463
|
+
for i in range(num_sources):
|
|
464
|
+
endpoint_ref = coremidi.MIDIGetSource(i)
|
|
465
|
+
if endpoint_ref == 0:
|
|
466
|
+
continue
|
|
467
|
+
|
|
468
|
+
# Get unique ID
|
|
469
|
+
unique_id = c_int32()
|
|
470
|
+
status = coremidi.MIDIObjectGetIntegerProperty(
|
|
471
|
+
endpoint_ref,
|
|
472
|
+
cf.CFStringCreateWithCString(None, b"uniqueID", 0),
|
|
473
|
+
byref(unique_id)
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# Get display name
|
|
477
|
+
name = _get_endpoint_display_name(coremidi, cf, endpoint_ref)
|
|
478
|
+
|
|
479
|
+
sources.append(MIDISource(
|
|
480
|
+
endpoint_ref=endpoint_ref,
|
|
481
|
+
unique_id=unique_id.value,
|
|
482
|
+
name=name
|
|
483
|
+
))
|
|
484
|
+
|
|
485
|
+
return sources
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def get_source_by_unique_id(unique_id: int) -> Optional[MIDISource]:
|
|
489
|
+
"""
|
|
490
|
+
Find a MIDI source by its unique ID.
|
|
491
|
+
|
|
492
|
+
Args:
|
|
493
|
+
unique_id: The unique identifier of the source.
|
|
494
|
+
|
|
495
|
+
Returns:
|
|
496
|
+
MIDISource if found, None otherwise.
|
|
497
|
+
"""
|
|
498
|
+
for src in get_sources():
|
|
499
|
+
if src.unique_id == unique_id:
|
|
500
|
+
return src
|
|
501
|
+
return None
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
# =============================================================================
|
|
505
|
+
# Driver installation
|
|
506
|
+
# =============================================================================
|
|
507
|
+
|
|
508
|
+
def install_driver_if_necessary() -> Optional[str]:
|
|
509
|
+
"""
|
|
510
|
+
Install the MIDI spy driver if it's not already installed.
|
|
511
|
+
|
|
512
|
+
This function must be called before creating a MIDIOutputClient. The driver
|
|
513
|
+
enables capturing outgoing MIDI data.
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
None on success, or an error message string on failure.
|
|
517
|
+
|
|
518
|
+
Note:
|
|
519
|
+
The driver is installed to ~/Library/Audio/MIDI Drivers/
|
|
520
|
+
You may need to restart MIDI applications after installation.
|
|
521
|
+
"""
|
|
522
|
+
spy = _get_spy_framework()
|
|
523
|
+
|
|
524
|
+
# MIDISpyInstallDriverIfNecessary returns NSError* or NULL
|
|
525
|
+
spy.MIDISpyInstallDriverIfNecessary.argtypes = []
|
|
526
|
+
spy.MIDISpyInstallDriverIfNecessary.restype = c_void_p
|
|
527
|
+
|
|
528
|
+
error_ptr = spy.MIDISpyInstallDriverIfNecessary()
|
|
529
|
+
|
|
530
|
+
if error_ptr is None or error_ptr == 0:
|
|
531
|
+
return None
|
|
532
|
+
|
|
533
|
+
# Try to get error description
|
|
534
|
+
# Load Foundation for NSError handling
|
|
535
|
+
try:
|
|
536
|
+
foundation = ctypes.CDLL("/System/Library/Frameworks/Foundation.framework/Foundation")
|
|
537
|
+
# In practice, we'd extract the localized description, but for simplicity:
|
|
538
|
+
return "Driver installation failed (check that the framework bundle contains the driver)"
|
|
539
|
+
except:
|
|
540
|
+
return "Driver installation failed"
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# =============================================================================
|
|
544
|
+
# MIDIOutputClient class (captures outgoing MIDI)
|
|
545
|
+
# =============================================================================
|
|
546
|
+
|
|
547
|
+
# Callback type for Python users
|
|
548
|
+
MIDICallback = Callable[[List[MIDIMessage], int], None]
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
class MIDIOutputClient:
|
|
552
|
+
"""
|
|
553
|
+
A client for capturing outgoing MIDI messages sent to destinations.
|
|
554
|
+
|
|
555
|
+
This class wraps the SnoizeMIDISpy framework to enable capturing MIDI
|
|
556
|
+
data that is being sent to MIDI destinations by other applications.
|
|
557
|
+
|
|
558
|
+
Example:
|
|
559
|
+
def on_midi(messages, endpoint_id):
|
|
560
|
+
for msg in messages:
|
|
561
|
+
print(f"MIDI: {msg}")
|
|
562
|
+
|
|
563
|
+
client = MIDIOutputClient(callback=on_midi)
|
|
564
|
+
client.connect_destination(destination_unique_id)
|
|
565
|
+
|
|
566
|
+
# ... keep running ...
|
|
567
|
+
|
|
568
|
+
client.close()
|
|
569
|
+
"""
|
|
570
|
+
|
|
571
|
+
def __init__(self, callback: MIDICallback, message_filter=None, framework_path: Optional[str] = None):
|
|
572
|
+
"""
|
|
573
|
+
Create a new MIDI output client for capturing outgoing MIDI.
|
|
574
|
+
|
|
575
|
+
Args:
|
|
576
|
+
callback: Function called when MIDI messages are captured.
|
|
577
|
+
Signature: callback(messages: List[MIDIMessage], source_endpoint_unique_id: int)
|
|
578
|
+
message_filter: Optional MessageFilter to filter messages before callback.
|
|
579
|
+
framework_path: Optional path to the SnoizeMIDISpy framework.
|
|
580
|
+
|
|
581
|
+
Raises:
|
|
582
|
+
DriverMissingError: If the spy driver is not installed.
|
|
583
|
+
DriverCommunicationError: If communication with the driver fails.
|
|
584
|
+
"""
|
|
585
|
+
self._callback = callback
|
|
586
|
+
self._message_filter = message_filter
|
|
587
|
+
self._spy = _get_spy_framework()
|
|
588
|
+
self._client_ref = c_void_p()
|
|
589
|
+
self._port_ref = c_void_p()
|
|
590
|
+
self._connected_endpoints: Set[int] = set()
|
|
591
|
+
self._lock = threading.Lock()
|
|
592
|
+
self._closed = False
|
|
593
|
+
|
|
594
|
+
# Keep a reference to the callback to prevent garbage collection
|
|
595
|
+
self._c_callback = self._create_c_callback()
|
|
596
|
+
|
|
597
|
+
# Set up function signatures
|
|
598
|
+
self._setup_function_signatures()
|
|
599
|
+
|
|
600
|
+
# Create the client
|
|
601
|
+
status = self._spy.MIDISpyClientCreate(byref(self._client_ref))
|
|
602
|
+
_check_status(status, "MIDISpyClientCreate")
|
|
603
|
+
|
|
604
|
+
# Create a port with our callback
|
|
605
|
+
status = self._spy.MIDISpyPortCreate(
|
|
606
|
+
self._client_ref,
|
|
607
|
+
self._c_callback,
|
|
608
|
+
byref(self._port_ref)
|
|
609
|
+
)
|
|
610
|
+
_check_status(status, "MIDISpyPortCreate")
|
|
611
|
+
|
|
612
|
+
def _setup_function_signatures(self):
|
|
613
|
+
"""Set up ctypes function signatures for the spy framework."""
|
|
614
|
+
self._spy.MIDISpyClientCreate.argtypes = [POINTER(c_void_p)]
|
|
615
|
+
self._spy.MIDISpyClientCreate.restype = c_int32
|
|
616
|
+
|
|
617
|
+
self._spy.MIDISpyClientDispose.argtypes = [c_void_p]
|
|
618
|
+
self._spy.MIDISpyClientDispose.restype = c_int32
|
|
619
|
+
|
|
620
|
+
self._spy.MIDISpyPortCreate.argtypes = [c_void_p, c_void_p, POINTER(c_void_p)]
|
|
621
|
+
self._spy.MIDISpyPortCreate.restype = c_int32
|
|
622
|
+
|
|
623
|
+
self._spy.MIDISpyPortDispose.argtypes = [c_void_p]
|
|
624
|
+
self._spy.MIDISpyPortDispose.restype = c_int32
|
|
625
|
+
|
|
626
|
+
self._spy.MIDISpyPortConnectDestination.argtypes = [c_void_p, MIDIEndpointRef, c_void_p]
|
|
627
|
+
self._spy.MIDISpyPortConnectDestination.restype = c_int32
|
|
628
|
+
|
|
629
|
+
self._spy.MIDISpyPortDisconnectDestination.argtypes = [c_void_p, MIDIEndpointRef]
|
|
630
|
+
self._spy.MIDISpyPortDisconnectDestination.restype = c_int32
|
|
631
|
+
|
|
632
|
+
def _create_c_callback(self):
|
|
633
|
+
"""Create the C callback function for receiving MIDI data."""
|
|
634
|
+
# The MIDIReadBlock is an Objective-C block with signature:
|
|
635
|
+
# void (^)(const MIDIPacketList *pktlist, void *srcConnRefCon)
|
|
636
|
+
|
|
637
|
+
def callback(packet_list_ptr, ref_con):
|
|
638
|
+
if self._closed:
|
|
639
|
+
return
|
|
640
|
+
|
|
641
|
+
try:
|
|
642
|
+
# Parse the packet list
|
|
643
|
+
if packet_list_ptr is None:
|
|
644
|
+
return
|
|
645
|
+
|
|
646
|
+
# Convert to integer address if needed
|
|
647
|
+
if hasattr(packet_list_ptr, 'value'):
|
|
648
|
+
addr = packet_list_ptr.value
|
|
649
|
+
elif isinstance(packet_list_ptr, int):
|
|
650
|
+
addr = packet_list_ptr
|
|
651
|
+
else:
|
|
652
|
+
addr = int(packet_list_ptr)
|
|
653
|
+
|
|
654
|
+
if addr == 0:
|
|
655
|
+
return
|
|
656
|
+
|
|
657
|
+
# Parse packets from the MIDIPacketList
|
|
658
|
+
messages = self._parse_packet_list(addr)
|
|
659
|
+
|
|
660
|
+
# Get the source endpoint from refCon
|
|
661
|
+
source_id = 0
|
|
662
|
+
if ref_con:
|
|
663
|
+
if hasattr(ref_con, 'value'):
|
|
664
|
+
source_id = ref_con.value
|
|
665
|
+
elif isinstance(ref_con, int):
|
|
666
|
+
source_id = ref_con
|
|
667
|
+
else:
|
|
668
|
+
source_id = int(ref_con)
|
|
669
|
+
|
|
670
|
+
# Call the user's callback
|
|
671
|
+
if messages:
|
|
672
|
+
# Apply filter if set
|
|
673
|
+
if self._message_filter is not None:
|
|
674
|
+
messages = self._message_filter.filter_messages(messages)
|
|
675
|
+
if messages:
|
|
676
|
+
self._callback(messages, source_id)
|
|
677
|
+
|
|
678
|
+
except Exception as e:
|
|
679
|
+
import sys
|
|
680
|
+
print(f"Error in MIDI callback: {e}", file=sys.stderr)
|
|
681
|
+
import traceback
|
|
682
|
+
traceback.print_exc()
|
|
683
|
+
|
|
684
|
+
# Create the block/callback
|
|
685
|
+
return _create_midi_read_block(callback)
|
|
686
|
+
|
|
687
|
+
def _parse_packet_list(self, packet_list_addr: int) -> List[MIDIMessage]:
|
|
688
|
+
"""Parse a MIDIPacketList pointer into MIDIMessage objects."""
|
|
689
|
+
messages = []
|
|
690
|
+
|
|
691
|
+
if not packet_list_addr:
|
|
692
|
+
return messages
|
|
693
|
+
|
|
694
|
+
# Read the number of packets
|
|
695
|
+
num_packets_ptr = ctypes.cast(packet_list_addr, POINTER(c_uint32))
|
|
696
|
+
num_packets = num_packets_ptr[0]
|
|
697
|
+
|
|
698
|
+
# Move to the first packet (after numPackets)
|
|
699
|
+
offset = 4 # sizeof(UInt32)
|
|
700
|
+
base_addr = packet_list_addr
|
|
701
|
+
|
|
702
|
+
for _ in range(num_packets):
|
|
703
|
+
# Read timestamp (8 bytes)
|
|
704
|
+
timestamp_ptr = ctypes.cast(base_addr + offset, POINTER(c_uint64))
|
|
705
|
+
timestamp = timestamp_ptr[0]
|
|
706
|
+
offset += 8
|
|
707
|
+
|
|
708
|
+
# Read length (2 bytes)
|
|
709
|
+
length_ptr = ctypes.cast(base_addr + offset, POINTER(c_uint16))
|
|
710
|
+
length = length_ptr[0]
|
|
711
|
+
offset += 2
|
|
712
|
+
|
|
713
|
+
# Read data
|
|
714
|
+
data_ptr = ctypes.cast(base_addr + offset, POINTER(c_uint8 * length))
|
|
715
|
+
midi_data = bytes(data_ptr[0])
|
|
716
|
+
offset += length
|
|
717
|
+
|
|
718
|
+
# Align to 4 bytes for next packet
|
|
719
|
+
remainder = offset % 4
|
|
720
|
+
if remainder != 0:
|
|
721
|
+
offset += 4 - remainder
|
|
722
|
+
|
|
723
|
+
messages.append(MIDIMessage(timestamp=timestamp, data=midi_data))
|
|
724
|
+
|
|
725
|
+
return messages
|
|
726
|
+
|
|
727
|
+
def connect_destination(self, destination: MIDIDestination):
|
|
728
|
+
"""
|
|
729
|
+
Start capturing MIDI messages sent to a destination.
|
|
730
|
+
|
|
731
|
+
Args:
|
|
732
|
+
destination: The MIDI destination to spy on.
|
|
733
|
+
|
|
734
|
+
Raises:
|
|
735
|
+
ConnectionExistsError: If already connected to this destination.
|
|
736
|
+
MIDISpyError: If connection fails.
|
|
737
|
+
"""
|
|
738
|
+
if self._closed:
|
|
739
|
+
raise MIDISpyError("Client is closed")
|
|
740
|
+
|
|
741
|
+
with self._lock:
|
|
742
|
+
if destination.endpoint_ref in self._connected_endpoints:
|
|
743
|
+
raise ConnectionExistsError(f"Already connected to {destination.name}")
|
|
744
|
+
|
|
745
|
+
# Use the unique_id as the connection refcon so we can identify the source
|
|
746
|
+
ref_con = c_void_p(destination.unique_id)
|
|
747
|
+
|
|
748
|
+
status = self._spy.MIDISpyPortConnectDestination(
|
|
749
|
+
self._port_ref,
|
|
750
|
+
destination.endpoint_ref,
|
|
751
|
+
ref_con
|
|
752
|
+
)
|
|
753
|
+
_check_status(status, f"Connect to {destination.name}")
|
|
754
|
+
|
|
755
|
+
self._connected_endpoints.add(destination.endpoint_ref)
|
|
756
|
+
|
|
757
|
+
def connect_destination_by_id(self, unique_id: int):
|
|
758
|
+
"""
|
|
759
|
+
Start capturing MIDI messages sent to a destination by its unique ID.
|
|
760
|
+
|
|
761
|
+
Args:
|
|
762
|
+
unique_id: The unique identifier of the destination.
|
|
763
|
+
|
|
764
|
+
Raises:
|
|
765
|
+
ValueError: If no destination with this ID exists.
|
|
766
|
+
ConnectionExistsError: If already connected to this destination.
|
|
767
|
+
"""
|
|
768
|
+
dest = get_destination_by_unique_id(unique_id)
|
|
769
|
+
if dest is None:
|
|
770
|
+
raise ValueError(f"No destination found with unique ID {unique_id}")
|
|
771
|
+
self.connect_destination(dest)
|
|
772
|
+
|
|
773
|
+
def disconnect_destination(self, destination: MIDIDestination):
|
|
774
|
+
"""
|
|
775
|
+
Stop capturing MIDI messages from a destination.
|
|
776
|
+
|
|
777
|
+
Args:
|
|
778
|
+
destination: The MIDI destination to disconnect.
|
|
779
|
+
|
|
780
|
+
Raises:
|
|
781
|
+
ConnectionNotFoundError: If not connected to this destination.
|
|
782
|
+
"""
|
|
783
|
+
if self._closed:
|
|
784
|
+
return
|
|
785
|
+
|
|
786
|
+
with self._lock:
|
|
787
|
+
if destination.endpoint_ref not in self._connected_endpoints:
|
|
788
|
+
raise ConnectionNotFoundError(f"Not connected to {destination.name}")
|
|
789
|
+
|
|
790
|
+
status = self._spy.MIDISpyPortDisconnectDestination(
|
|
791
|
+
self._port_ref,
|
|
792
|
+
destination.endpoint_ref
|
|
793
|
+
)
|
|
794
|
+
_check_status(status, f"Disconnect from {destination.name}")
|
|
795
|
+
|
|
796
|
+
self._connected_endpoints.discard(destination.endpoint_ref)
|
|
797
|
+
|
|
798
|
+
def disconnect_destination_by_id(self, unique_id: int):
|
|
799
|
+
"""
|
|
800
|
+
Stop capturing MIDI messages from a destination by its unique ID.
|
|
801
|
+
|
|
802
|
+
Args:
|
|
803
|
+
unique_id: The unique identifier of the destination.
|
|
804
|
+
"""
|
|
805
|
+
dest = get_destination_by_unique_id(unique_id)
|
|
806
|
+
if dest is None:
|
|
807
|
+
raise ValueError(f"No destination found with unique ID {unique_id}")
|
|
808
|
+
self.disconnect_destination(dest)
|
|
809
|
+
|
|
810
|
+
def disconnect_all(self):
|
|
811
|
+
"""Disconnect from all destinations."""
|
|
812
|
+
if self._closed:
|
|
813
|
+
return
|
|
814
|
+
|
|
815
|
+
# Get a snapshot of connected endpoints
|
|
816
|
+
with self._lock:
|
|
817
|
+
endpoints = list(self._connected_endpoints)
|
|
818
|
+
|
|
819
|
+
for endpoint_ref in endpoints:
|
|
820
|
+
try:
|
|
821
|
+
self._spy.MIDISpyPortDisconnectDestination(self._port_ref, endpoint_ref)
|
|
822
|
+
except:
|
|
823
|
+
pass
|
|
824
|
+
|
|
825
|
+
with self._lock:
|
|
826
|
+
self._connected_endpoints.clear()
|
|
827
|
+
|
|
828
|
+
@property
|
|
829
|
+
def connected_destinations(self) -> List[MIDIDestination]:
|
|
830
|
+
"""Get list of currently connected destinations."""
|
|
831
|
+
with self._lock:
|
|
832
|
+
endpoint_refs = set(self._connected_endpoints)
|
|
833
|
+
|
|
834
|
+
destinations = []
|
|
835
|
+
for dest in get_destinations():
|
|
836
|
+
if dest.endpoint_ref in endpoint_refs:
|
|
837
|
+
destinations.append(dest)
|
|
838
|
+
return destinations
|
|
839
|
+
|
|
840
|
+
@property
|
|
841
|
+
def message_filter(self):
|
|
842
|
+
"""Get the current message filter."""
|
|
843
|
+
return self._message_filter
|
|
844
|
+
|
|
845
|
+
@message_filter.setter
|
|
846
|
+
def message_filter(self, value):
|
|
847
|
+
"""Set the message filter."""
|
|
848
|
+
self._message_filter = value
|
|
849
|
+
|
|
850
|
+
def close(self):
|
|
851
|
+
"""
|
|
852
|
+
Close the MIDI spy client and release all resources.
|
|
853
|
+
|
|
854
|
+
This method is idempotent and can be called multiple times.
|
|
855
|
+
"""
|
|
856
|
+
if self._closed:
|
|
857
|
+
return
|
|
858
|
+
|
|
859
|
+
self._closed = True
|
|
860
|
+
|
|
861
|
+
# Disconnect all destinations
|
|
862
|
+
self.disconnect_all()
|
|
863
|
+
|
|
864
|
+
# Dispose the port
|
|
865
|
+
if self._port_ref:
|
|
866
|
+
try:
|
|
867
|
+
self._spy.MIDISpyPortDispose(self._port_ref)
|
|
868
|
+
except:
|
|
869
|
+
pass
|
|
870
|
+
self._port_ref = c_void_p()
|
|
871
|
+
|
|
872
|
+
# Dispose the client
|
|
873
|
+
if self._client_ref:
|
|
874
|
+
try:
|
|
875
|
+
self._spy.MIDISpyClientDispose(self._client_ref)
|
|
876
|
+
except:
|
|
877
|
+
pass
|
|
878
|
+
self._client_ref = c_void_p()
|
|
879
|
+
|
|
880
|
+
def __enter__(self):
|
|
881
|
+
"""Context manager entry."""
|
|
882
|
+
return self
|
|
883
|
+
|
|
884
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
885
|
+
"""Context manager exit - closes the client."""
|
|
886
|
+
self.close()
|
|
887
|
+
return False
|
|
888
|
+
|
|
889
|
+
def __del__(self):
|
|
890
|
+
"""Destructor - ensure resources are released."""
|
|
891
|
+
self.close()
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
# =============================================================================
|
|
895
|
+
# MIDIInputClient class - for receiving incoming MIDI
|
|
896
|
+
# =============================================================================
|
|
897
|
+
|
|
898
|
+
class MIDIInputClient:
|
|
899
|
+
"""
|
|
900
|
+
A client for receiving incoming MIDI messages from sources.
|
|
901
|
+
|
|
902
|
+
This class uses standard CoreMIDI APIs to receive MIDI data from
|
|
903
|
+
MIDI sources (inputs). This is the normal way to receive MIDI.
|
|
904
|
+
|
|
905
|
+
Example:
|
|
906
|
+
def on_midi(messages, source_id):
|
|
907
|
+
for msg in messages:
|
|
908
|
+
print(f"MIDI: {msg}")
|
|
909
|
+
|
|
910
|
+
client = MIDIInputClient(callback=on_midi)
|
|
911
|
+
client.connect_source(source)
|
|
912
|
+
|
|
913
|
+
# ... keep running ...
|
|
914
|
+
|
|
915
|
+
client.close()
|
|
916
|
+
"""
|
|
917
|
+
|
|
918
|
+
def __init__(self, callback: MIDICallback, client_name: str = "PythonMIDI", message_filter=None):
|
|
919
|
+
"""
|
|
920
|
+
Create a new MIDI input client.
|
|
921
|
+
|
|
922
|
+
Args:
|
|
923
|
+
callback: Function called when MIDI messages are received.
|
|
924
|
+
Signature: callback(messages: List[MIDIMessage], source_unique_id: int)
|
|
925
|
+
client_name: Name for the MIDI client (visible in system).
|
|
926
|
+
message_filter: Optional MessageFilter to filter messages before callback.
|
|
927
|
+
"""
|
|
928
|
+
self._callback = callback
|
|
929
|
+
self._message_filter = message_filter
|
|
930
|
+
self._coremidi = _get_coremidi()
|
|
931
|
+
self._cf = ctypes.CDLL("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")
|
|
932
|
+
|
|
933
|
+
self._client_ref = c_uint32()
|
|
934
|
+
self._port_ref = c_uint32()
|
|
935
|
+
self._connected_sources: Set[int] = set()
|
|
936
|
+
self._source_refcons: dict = {} # endpoint_ref -> unique_id
|
|
937
|
+
self._lock = threading.Lock()
|
|
938
|
+
self._closed = False
|
|
939
|
+
|
|
940
|
+
# Keep reference to callback to prevent GC
|
|
941
|
+
self._read_block = self._create_read_block()
|
|
942
|
+
|
|
943
|
+
# Set up function signatures
|
|
944
|
+
self._setup_function_signatures()
|
|
945
|
+
|
|
946
|
+
# Create MIDI client
|
|
947
|
+
client_name_cf = self._cf.CFStringCreateWithCString(None, client_name.encode('utf-8'), 0)
|
|
948
|
+
status = self._coremidi.MIDIClientCreate(client_name_cf, None, None, byref(self._client_ref))
|
|
949
|
+
if status != 0:
|
|
950
|
+
raise MIDISpyError(f"MIDIClientCreate failed with status {status}")
|
|
951
|
+
|
|
952
|
+
# Create input port with our callback
|
|
953
|
+
port_name_cf = self._cf.CFStringCreateWithCString(None, b"Input", 0)
|
|
954
|
+
status = self._coremidi.MIDIInputPortCreateWithBlock(
|
|
955
|
+
self._client_ref,
|
|
956
|
+
port_name_cf,
|
|
957
|
+
byref(self._port_ref),
|
|
958
|
+
self._read_block
|
|
959
|
+
)
|
|
960
|
+
if status != 0:
|
|
961
|
+
raise MIDISpyError(f"MIDIInputPortCreateWithBlock failed with status {status}")
|
|
962
|
+
|
|
963
|
+
def _setup_function_signatures(self):
|
|
964
|
+
"""Set up ctypes function signatures for CoreMIDI."""
|
|
965
|
+
self._coremidi.MIDIClientCreate.argtypes = [c_void_p, c_void_p, c_void_p, POINTER(c_uint32)]
|
|
966
|
+
self._coremidi.MIDIClientCreate.restype = c_int32
|
|
967
|
+
|
|
968
|
+
self._coremidi.MIDIClientDispose.argtypes = [c_uint32]
|
|
969
|
+
self._coremidi.MIDIClientDispose.restype = c_int32
|
|
970
|
+
|
|
971
|
+
self._coremidi.MIDIInputPortCreateWithBlock.argtypes = [c_uint32, c_void_p, POINTER(c_uint32), c_void_p]
|
|
972
|
+
self._coremidi.MIDIInputPortCreateWithBlock.restype = c_int32
|
|
973
|
+
|
|
974
|
+
self._coremidi.MIDIPortDispose.argtypes = [c_uint32]
|
|
975
|
+
self._coremidi.MIDIPortDispose.restype = c_int32
|
|
976
|
+
|
|
977
|
+
self._coremidi.MIDIPortConnectSource.argtypes = [c_uint32, c_uint32, c_void_p]
|
|
978
|
+
self._coremidi.MIDIPortConnectSource.restype = c_int32
|
|
979
|
+
|
|
980
|
+
self._coremidi.MIDIPortDisconnectSource.argtypes = [c_uint32, c_uint32]
|
|
981
|
+
self._coremidi.MIDIPortDisconnectSource.restype = c_int32
|
|
982
|
+
|
|
983
|
+
def _create_read_block(self):
|
|
984
|
+
"""Create the read block callback."""
|
|
985
|
+
def callback(packet_list_ptr, ref_con):
|
|
986
|
+
if self._closed:
|
|
987
|
+
return
|
|
988
|
+
|
|
989
|
+
try:
|
|
990
|
+
if packet_list_ptr is None:
|
|
991
|
+
return
|
|
992
|
+
|
|
993
|
+
# Convert to integer address
|
|
994
|
+
if hasattr(packet_list_ptr, 'value'):
|
|
995
|
+
addr = packet_list_ptr.value
|
|
996
|
+
elif isinstance(packet_list_ptr, int):
|
|
997
|
+
addr = packet_list_ptr
|
|
998
|
+
else:
|
|
999
|
+
addr = int(packet_list_ptr)
|
|
1000
|
+
|
|
1001
|
+
if addr == 0:
|
|
1002
|
+
return
|
|
1003
|
+
|
|
1004
|
+
# Parse packets
|
|
1005
|
+
messages = self._parse_packet_list(addr)
|
|
1006
|
+
|
|
1007
|
+
# Get source ID from refcon
|
|
1008
|
+
source_id = 0
|
|
1009
|
+
if ref_con:
|
|
1010
|
+
if hasattr(ref_con, 'value'):
|
|
1011
|
+
source_id = ref_con.value if ref_con.value else 0
|
|
1012
|
+
elif isinstance(ref_con, int):
|
|
1013
|
+
source_id = ref_con
|
|
1014
|
+
else:
|
|
1015
|
+
try:
|
|
1016
|
+
source_id = int(ref_con)
|
|
1017
|
+
except:
|
|
1018
|
+
source_id = 0
|
|
1019
|
+
|
|
1020
|
+
if messages:
|
|
1021
|
+
# Apply filter if set
|
|
1022
|
+
if self._message_filter is not None:
|
|
1023
|
+
messages = self._message_filter.filter_messages(messages)
|
|
1024
|
+
if messages:
|
|
1025
|
+
self._callback(messages, source_id)
|
|
1026
|
+
|
|
1027
|
+
except Exception as e:
|
|
1028
|
+
import sys
|
|
1029
|
+
print(f"Error in MIDI input callback: {e}", file=sys.stderr)
|
|
1030
|
+
|
|
1031
|
+
return _create_midi_read_block(callback)
|
|
1032
|
+
|
|
1033
|
+
def _parse_packet_list(self, packet_list_addr: int) -> List[MIDIMessage]:
|
|
1034
|
+
"""Parse a MIDIPacketList pointer into MIDIMessage objects."""
|
|
1035
|
+
messages = []
|
|
1036
|
+
|
|
1037
|
+
if not packet_list_addr:
|
|
1038
|
+
return messages
|
|
1039
|
+
|
|
1040
|
+
# Read the number of packets
|
|
1041
|
+
num_packets_ptr = ctypes.cast(packet_list_addr, POINTER(c_uint32))
|
|
1042
|
+
num_packets = num_packets_ptr[0]
|
|
1043
|
+
|
|
1044
|
+
# Move to the first packet (after numPackets)
|
|
1045
|
+
offset = 4 # sizeof(UInt32)
|
|
1046
|
+
base_addr = packet_list_addr
|
|
1047
|
+
|
|
1048
|
+
for _ in range(num_packets):
|
|
1049
|
+
# Read timestamp (8 bytes)
|
|
1050
|
+
timestamp_ptr = ctypes.cast(base_addr + offset, POINTER(c_uint64))
|
|
1051
|
+
timestamp = timestamp_ptr[0]
|
|
1052
|
+
offset += 8
|
|
1053
|
+
|
|
1054
|
+
# Read length (2 bytes)
|
|
1055
|
+
length_ptr = ctypes.cast(base_addr + offset, POINTER(c_uint16))
|
|
1056
|
+
length = length_ptr[0]
|
|
1057
|
+
offset += 2
|
|
1058
|
+
|
|
1059
|
+
# Read data
|
|
1060
|
+
data_ptr = ctypes.cast(base_addr + offset, POINTER(c_uint8 * length))
|
|
1061
|
+
midi_data = bytes(data_ptr[0])
|
|
1062
|
+
offset += length
|
|
1063
|
+
|
|
1064
|
+
# Align to 4 bytes for next packet
|
|
1065
|
+
remainder = offset % 4
|
|
1066
|
+
if remainder != 0:
|
|
1067
|
+
offset += 4 - remainder
|
|
1068
|
+
|
|
1069
|
+
messages.append(MIDIMessage(timestamp=timestamp, data=midi_data))
|
|
1070
|
+
|
|
1071
|
+
return messages
|
|
1072
|
+
|
|
1073
|
+
def connect_source(self, source: MIDISource):
|
|
1074
|
+
"""
|
|
1075
|
+
Start receiving MIDI messages from a source.
|
|
1076
|
+
|
|
1077
|
+
Args:
|
|
1078
|
+
source: The MIDI source to connect to.
|
|
1079
|
+
"""
|
|
1080
|
+
if self._closed:
|
|
1081
|
+
raise MIDISpyError("Client is closed")
|
|
1082
|
+
|
|
1083
|
+
with self._lock:
|
|
1084
|
+
if source.endpoint_ref in self._connected_sources:
|
|
1085
|
+
raise ConnectionExistsError(f"Already connected to {source.name}")
|
|
1086
|
+
|
|
1087
|
+
# Use unique_id as refcon
|
|
1088
|
+
ref_con = c_void_p(source.unique_id)
|
|
1089
|
+
self._source_refcons[source.endpoint_ref] = source.unique_id
|
|
1090
|
+
|
|
1091
|
+
status = self._coremidi.MIDIPortConnectSource(
|
|
1092
|
+
self._port_ref,
|
|
1093
|
+
source.endpoint_ref,
|
|
1094
|
+
ref_con
|
|
1095
|
+
)
|
|
1096
|
+
if status != 0:
|
|
1097
|
+
raise MIDISpyError(f"MIDIPortConnectSource failed with status {status}")
|
|
1098
|
+
|
|
1099
|
+
self._connected_sources.add(source.endpoint_ref)
|
|
1100
|
+
|
|
1101
|
+
def connect_source_by_id(self, unique_id: int):
|
|
1102
|
+
"""Start receiving MIDI from a source by its unique ID."""
|
|
1103
|
+
src = get_source_by_unique_id(unique_id)
|
|
1104
|
+
if src is None:
|
|
1105
|
+
raise ValueError(f"No source found with unique ID {unique_id}")
|
|
1106
|
+
self.connect_source(src)
|
|
1107
|
+
|
|
1108
|
+
def disconnect_source(self, source: MIDISource):
|
|
1109
|
+
"""Stop receiving MIDI messages from a source."""
|
|
1110
|
+
if self._closed:
|
|
1111
|
+
return
|
|
1112
|
+
|
|
1113
|
+
with self._lock:
|
|
1114
|
+
if source.endpoint_ref not in self._connected_sources:
|
|
1115
|
+
raise ConnectionNotFoundError(f"Not connected to {source.name}")
|
|
1116
|
+
|
|
1117
|
+
status = self._coremidi.MIDIPortDisconnectSource(
|
|
1118
|
+
self._port_ref,
|
|
1119
|
+
source.endpoint_ref
|
|
1120
|
+
)
|
|
1121
|
+
if status != 0:
|
|
1122
|
+
raise MIDISpyError(f"MIDIPortDisconnectSource failed with status {status}")
|
|
1123
|
+
|
|
1124
|
+
self._connected_sources.discard(source.endpoint_ref)
|
|
1125
|
+
self._source_refcons.pop(source.endpoint_ref, None)
|
|
1126
|
+
|
|
1127
|
+
def disconnect_source_by_id(self, unique_id: int):
|
|
1128
|
+
"""Stop receiving MIDI from a source by its unique ID."""
|
|
1129
|
+
src = get_source_by_unique_id(unique_id)
|
|
1130
|
+
if src is None:
|
|
1131
|
+
raise ValueError(f"No source found with unique ID {unique_id}")
|
|
1132
|
+
self.disconnect_source(src)
|
|
1133
|
+
|
|
1134
|
+
def disconnect_all(self):
|
|
1135
|
+
"""Disconnect from all sources."""
|
|
1136
|
+
if self._closed:
|
|
1137
|
+
return
|
|
1138
|
+
|
|
1139
|
+
with self._lock:
|
|
1140
|
+
endpoints = list(self._connected_sources)
|
|
1141
|
+
|
|
1142
|
+
for endpoint_ref in endpoints:
|
|
1143
|
+
try:
|
|
1144
|
+
self._coremidi.MIDIPortDisconnectSource(self._port_ref, endpoint_ref)
|
|
1145
|
+
except:
|
|
1146
|
+
pass
|
|
1147
|
+
|
|
1148
|
+
with self._lock:
|
|
1149
|
+
self._connected_sources.clear()
|
|
1150
|
+
self._source_refcons.clear()
|
|
1151
|
+
|
|
1152
|
+
@property
|
|
1153
|
+
def connected_sources(self) -> List[MIDISource]:
|
|
1154
|
+
"""Get list of currently connected sources."""
|
|
1155
|
+
with self._lock:
|
|
1156
|
+
endpoint_refs = set(self._connected_sources)
|
|
1157
|
+
|
|
1158
|
+
sources = []
|
|
1159
|
+
for src in get_sources():
|
|
1160
|
+
if src.endpoint_ref in endpoint_refs:
|
|
1161
|
+
sources.append(src)
|
|
1162
|
+
return sources
|
|
1163
|
+
|
|
1164
|
+
@property
|
|
1165
|
+
def message_filter(self):
|
|
1166
|
+
"""Get the current message filter."""
|
|
1167
|
+
return self._message_filter
|
|
1168
|
+
|
|
1169
|
+
@message_filter.setter
|
|
1170
|
+
def message_filter(self, value):
|
|
1171
|
+
"""Set the message filter."""
|
|
1172
|
+
self._message_filter = value
|
|
1173
|
+
|
|
1174
|
+
def close(self):
|
|
1175
|
+
"""Close the MIDI input client and release resources."""
|
|
1176
|
+
if self._closed:
|
|
1177
|
+
return
|
|
1178
|
+
|
|
1179
|
+
self._closed = True
|
|
1180
|
+
|
|
1181
|
+
self.disconnect_all()
|
|
1182
|
+
|
|
1183
|
+
if self._port_ref:
|
|
1184
|
+
try:
|
|
1185
|
+
self._coremidi.MIDIPortDispose(self._port_ref)
|
|
1186
|
+
except:
|
|
1187
|
+
pass
|
|
1188
|
+
self._port_ref = c_uint32()
|
|
1189
|
+
|
|
1190
|
+
if self._client_ref:
|
|
1191
|
+
try:
|
|
1192
|
+
self._coremidi.MIDIClientDispose(self._client_ref)
|
|
1193
|
+
except:
|
|
1194
|
+
pass
|
|
1195
|
+
self._client_ref = c_uint32()
|
|
1196
|
+
|
|
1197
|
+
def __enter__(self):
|
|
1198
|
+
return self
|
|
1199
|
+
|
|
1200
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
1201
|
+
self.close()
|
|
1202
|
+
return False
|
|
1203
|
+
|
|
1204
|
+
def __del__(self):
|
|
1205
|
+
self.close()
|