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,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()