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,430 @@
1
+ """
2
+ MIDI message utilities and parsing helpers.
3
+ """
4
+
5
+ from typing import List, Tuple, Optional, Set, Union
6
+ from dataclasses import dataclass, field
7
+
8
+ # MIDI Status Bytes
9
+ NOTE_OFF = 0x80
10
+ NOTE_ON = 0x90
11
+ POLY_PRESSURE = 0xA0
12
+ CONTROL_CHANGE = 0xB0
13
+ PROGRAM_CHANGE = 0xC0
14
+ CHANNEL_PRESSURE = 0xD0
15
+ PITCH_BEND = 0xE0
16
+ SYSEX_START = 0xF0
17
+ MTC_QUARTER_FRAME = 0xF1
18
+ SONG_POSITION = 0xF2
19
+ SONG_SELECT = 0xF3
20
+ TUNE_REQUEST = 0xF6
21
+ SYSEX_END = 0xF7
22
+ TIMING_CLOCK = 0xF8
23
+ START = 0xFA
24
+ CONTINUE = 0xFB
25
+ STOP = 0xFC
26
+ ACTIVE_SENSING = 0xFE
27
+ SYSTEM_RESET = 0xFF
28
+
29
+ # Message type constants for filtering
30
+ MSG_NOTE_OFF = "note_off"
31
+ MSG_NOTE_ON = "note_on"
32
+ MSG_NOTE = "note" # Both note on and note off
33
+ MSG_POLY_PRESSURE = "poly_pressure"
34
+ MSG_CONTROL_CHANGE = "control_change"
35
+ MSG_PROGRAM_CHANGE = "program_change"
36
+ MSG_CHANNEL_PRESSURE = "channel_pressure"
37
+ MSG_PITCH_BEND = "pitch_bend"
38
+ MSG_SYSEX = "sysex"
39
+ MSG_TIMING_CLOCK = "timing_clock"
40
+ MSG_TRANSPORT = "transport" # Start, Stop, Continue
41
+ MSG_ACTIVE_SENSING = "active_sensing"
42
+ MSG_REALTIME = "realtime" # All realtime messages (clock, transport, active sensing)
43
+ MSG_CHANNEL = "channel" # All channel messages
44
+ MSG_SYSTEM = "system" # All system messages
45
+
46
+ # Note names
47
+ NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
48
+
49
+
50
+ def note_name(note_number: int) -> str:
51
+ """
52
+ Convert a MIDI note number to a note name with octave.
53
+
54
+ Args:
55
+ note_number: MIDI note number (0-127)
56
+
57
+ Returns:
58
+ Note name like "C4", "F#2", etc.
59
+ """
60
+ octave = (note_number // 12) - 1
61
+ name = NOTE_NAMES[note_number % 12]
62
+ return f"{name}{octave}"
63
+
64
+
65
+ def note_number(name: str) -> int:
66
+ """
67
+ Convert a note name to a MIDI note number.
68
+
69
+ Args:
70
+ name: Note name like "C4", "F#2", "Bb3"
71
+
72
+ Returns:
73
+ MIDI note number (0-127)
74
+ """
75
+ name = name.strip().upper()
76
+
77
+ # Handle flats by converting to sharps
78
+ name = name.replace('BB', 'A#').replace('DB', 'C#').replace('EB', 'D#')
79
+ name = name.replace('GB', 'F#').replace('AB', 'G#')
80
+
81
+ # Parse note and octave
82
+ if len(name) >= 2 and name[1] == '#':
83
+ note_part = name[:2]
84
+ octave = int(name[2:]) if len(name) > 2 else 4
85
+ else:
86
+ note_part = name[0]
87
+ octave = int(name[1:]) if len(name) > 1 else 4
88
+
89
+ note_index = NOTE_NAMES.index(note_part)
90
+ return (octave + 1) * 12 + note_index
91
+
92
+
93
+ @dataclass
94
+ class ParsedMIDIMessage:
95
+ """A decoded MIDI message with human-readable fields."""
96
+ raw_data: bytes
97
+ message_type: str
98
+ channel: Optional[int] = None # 1-16 for channel messages
99
+ note: Optional[int] = None
100
+ velocity: Optional[int] = None
101
+ controller: Optional[int] = None
102
+ value: Optional[int] = None
103
+ program: Optional[int] = None
104
+ pressure: Optional[int] = None
105
+ pitch_bend: Optional[int] = None # -8192 to 8191
106
+
107
+ @property
108
+ def note_name(self) -> Optional[str]:
109
+ """Get the note name if this is a note message."""
110
+ if self.note is not None:
111
+ return note_name(self.note)
112
+ return None
113
+
114
+ def __str__(self) -> str:
115
+ parts = [self.message_type]
116
+ if self.channel is not None:
117
+ parts.append(f"Ch{self.channel}")
118
+ if self.note is not None:
119
+ parts.append(f"Note={self.note_name}")
120
+ if self.velocity is not None:
121
+ parts.append(f"Vel={self.velocity}")
122
+ if self.controller is not None:
123
+ parts.append(f"CC{self.controller}={self.value}")
124
+ if self.program is not None:
125
+ parts.append(f"Prog={self.program}")
126
+ if self.pressure is not None:
127
+ parts.append(f"Press={self.pressure}")
128
+ if self.pitch_bend is not None:
129
+ parts.append(f"PB={self.pitch_bend}")
130
+ return " ".join(parts)
131
+
132
+
133
+ def parse_midi_message(data: bytes) -> ParsedMIDIMessage:
134
+ """
135
+ Parse raw MIDI bytes into a structured message.
136
+
137
+ Args:
138
+ data: Raw MIDI bytes
139
+
140
+ Returns:
141
+ ParsedMIDIMessage with decoded fields
142
+ """
143
+ if not data:
144
+ return ParsedMIDIMessage(raw_data=data, message_type="Empty")
145
+
146
+ status = data[0]
147
+
148
+ # Channel messages
149
+ if status < 0xF0:
150
+ channel = (status & 0x0F) + 1 # 1-16
151
+ msg_type = status & 0xF0
152
+
153
+ if msg_type == NOTE_OFF:
154
+ return ParsedMIDIMessage(
155
+ raw_data=data,
156
+ message_type="Note Off",
157
+ channel=channel,
158
+ note=data[1] if len(data) > 1 else None,
159
+ velocity=data[2] if len(data) > 2 else 0
160
+ )
161
+ elif msg_type == NOTE_ON:
162
+ vel = data[2] if len(data) > 2 else 0
163
+ # Note On with velocity 0 is equivalent to Note Off
164
+ msg_name = "Note On" if vel > 0 else "Note Off"
165
+ return ParsedMIDIMessage(
166
+ raw_data=data,
167
+ message_type=msg_name,
168
+ channel=channel,
169
+ note=data[1] if len(data) > 1 else None,
170
+ velocity=vel
171
+ )
172
+ elif msg_type == POLY_PRESSURE:
173
+ return ParsedMIDIMessage(
174
+ raw_data=data,
175
+ message_type="Poly Pressure",
176
+ channel=channel,
177
+ note=data[1] if len(data) > 1 else None,
178
+ pressure=data[2] if len(data) > 2 else None
179
+ )
180
+ elif msg_type == CONTROL_CHANGE:
181
+ return ParsedMIDIMessage(
182
+ raw_data=data,
183
+ message_type="Control Change",
184
+ channel=channel,
185
+ controller=data[1] if len(data) > 1 else None,
186
+ value=data[2] if len(data) > 2 else None
187
+ )
188
+ elif msg_type == PROGRAM_CHANGE:
189
+ return ParsedMIDIMessage(
190
+ raw_data=data,
191
+ message_type="Program Change",
192
+ channel=channel,
193
+ program=data[1] if len(data) > 1 else None
194
+ )
195
+ elif msg_type == CHANNEL_PRESSURE:
196
+ return ParsedMIDIMessage(
197
+ raw_data=data,
198
+ message_type="Channel Pressure",
199
+ channel=channel,
200
+ pressure=data[1] if len(data) > 1 else None
201
+ )
202
+ elif msg_type == PITCH_BEND:
203
+ if len(data) >= 3:
204
+ # Pitch bend is 14-bit, LSB first
205
+ lsb = data[1]
206
+ msb = data[2]
207
+ bend = ((msb << 7) | lsb) - 8192 # Center at 0
208
+ else:
209
+ bend = None
210
+ return ParsedMIDIMessage(
211
+ raw_data=data,
212
+ message_type="Pitch Bend",
213
+ channel=channel,
214
+ pitch_bend=bend
215
+ )
216
+
217
+ # System messages
218
+ system_names = {
219
+ SYSEX_START: "SysEx",
220
+ MTC_QUARTER_FRAME: "MTC Quarter Frame",
221
+ SONG_POSITION: "Song Position",
222
+ SONG_SELECT: "Song Select",
223
+ TUNE_REQUEST: "Tune Request",
224
+ SYSEX_END: "SysEx End",
225
+ TIMING_CLOCK: "Timing Clock",
226
+ START: "Start",
227
+ CONTINUE: "Continue",
228
+ STOP: "Stop",
229
+ ACTIVE_SENSING: "Active Sensing",
230
+ SYSTEM_RESET: "System Reset",
231
+ }
232
+
233
+ msg_type = system_names.get(status, f"Unknown (0x{status:02X})")
234
+ return ParsedMIDIMessage(raw_data=data, message_type=msg_type)
235
+
236
+
237
+ # Controller number names (most common)
238
+ CONTROLLER_NAMES = {
239
+ 0: "Bank Select MSB",
240
+ 1: "Modulation Wheel",
241
+ 2: "Breath Controller",
242
+ 4: "Foot Controller",
243
+ 5: "Portamento Time",
244
+ 6: "Data Entry MSB",
245
+ 7: "Channel Volume",
246
+ 8: "Balance",
247
+ 10: "Pan",
248
+ 11: "Expression",
249
+ 32: "Bank Select LSB",
250
+ 64: "Sustain Pedal",
251
+ 65: "Portamento",
252
+ 66: "Sostenuto",
253
+ 67: "Soft Pedal",
254
+ 68: "Legato Footswitch",
255
+ 69: "Hold 2",
256
+ 120: "All Sound Off",
257
+ 121: "Reset All Controllers",
258
+ 122: "Local Control",
259
+ 123: "All Notes Off",
260
+ 124: "Omni Off",
261
+ 125: "Omni On",
262
+ 126: "Mono On",
263
+ 127: "Poly On",
264
+ }
265
+
266
+
267
+ def controller_name(cc_number: int) -> str:
268
+ """Get the name of a MIDI controller number."""
269
+ return CONTROLLER_NAMES.get(cc_number, f"CC {cc_number}")
270
+
271
+
272
+ @dataclass
273
+ class MessageFilter:
274
+ """
275
+ Filter for MIDI messages by type, channel, or other criteria.
276
+
277
+ Use with MIDIInputClient or MIDIOutputClient to filter incoming messages
278
+ before they reach your callback.
279
+
280
+ Example:
281
+ # Only note messages on channel 1
282
+ filter = MessageFilter(types=["note"], channels=[1])
283
+
284
+ # Exclude timing clock and active sensing (common noise)
285
+ filter = MessageFilter(exclude_types=["timing_clock", "active_sensing"])
286
+
287
+ # Only specific controllers
288
+ filter = MessageFilter(types=["control_change"], controllers=[1, 7, 10])
289
+
290
+ client = MIDIInputClient(callback=on_midi, message_filter=filter)
291
+
292
+ Message type strings:
293
+ - "note_off", "note_on", "note" (both on/off)
294
+ - "poly_pressure", "channel_pressure"
295
+ - "control_change", "program_change", "pitch_bend"
296
+ - "sysex"
297
+ - "timing_clock", "active_sensing"
298
+ - "transport" (start, stop, continue)
299
+ - "realtime" (clock, transport, active sensing)
300
+ - "channel" (all channel voice messages)
301
+ - "system" (all system messages)
302
+ """
303
+ types: Optional[Set[str]] = field(default=None)
304
+ exclude_types: Optional[Set[str]] = field(default=None)
305
+ channels: Optional[Set[int]] = field(default=None) # 1-16
306
+ exclude_channels: Optional[Set[int]] = field(default=None)
307
+ controllers: Optional[Set[int]] = field(default=None) # For CC messages
308
+ notes: Optional[Set[int]] = field(default=None) # Note numbers 0-127
309
+
310
+ def __post_init__(self):
311
+ # Convert lists to sets for faster lookup
312
+ if self.types is not None and not isinstance(self.types, set):
313
+ self.types = set(self.types)
314
+ if self.exclude_types is not None and not isinstance(self.exclude_types, set):
315
+ self.exclude_types = set(self.exclude_types)
316
+ if self.channels is not None and not isinstance(self.channels, set):
317
+ self.channels = set(self.channels)
318
+ if self.exclude_channels is not None and not isinstance(self.exclude_channels, set):
319
+ self.exclude_channels = set(self.exclude_channels)
320
+ if self.controllers is not None and not isinstance(self.controllers, set):
321
+ self.controllers = set(self.controllers)
322
+ if self.notes is not None and not isinstance(self.notes, set):
323
+ self.notes = set(self.notes)
324
+
325
+ def matches(self, data: bytes) -> bool:
326
+ """
327
+ Check if a MIDI message matches this filter.
328
+
329
+ Args:
330
+ data: Raw MIDI bytes
331
+
332
+ Returns:
333
+ True if the message should be passed through, False if filtered out.
334
+ """
335
+ if not data:
336
+ return False
337
+
338
+ status = data[0]
339
+
340
+ # Determine message type and properties
341
+ msg_types = set()
342
+ channel = None
343
+ controller = None
344
+ note = None
345
+
346
+ if status < 0xF0:
347
+ # Channel message
348
+ channel = (status & 0x0F) + 1 # 1-16
349
+ msg_type_byte = status & 0xF0
350
+ msg_types.add(MSG_CHANNEL)
351
+
352
+ if msg_type_byte == NOTE_OFF:
353
+ msg_types.add(MSG_NOTE_OFF)
354
+ msg_types.add(MSG_NOTE)
355
+ note = data[1] if len(data) > 1 else None
356
+ elif msg_type_byte == NOTE_ON:
357
+ vel = data[2] if len(data) > 2 else 0
358
+ if vel > 0:
359
+ msg_types.add(MSG_NOTE_ON)
360
+ else:
361
+ msg_types.add(MSG_NOTE_OFF) # Note On vel=0 is Note Off
362
+ msg_types.add(MSG_NOTE)
363
+ note = data[1] if len(data) > 1 else None
364
+ elif msg_type_byte == POLY_PRESSURE:
365
+ msg_types.add(MSG_POLY_PRESSURE)
366
+ note = data[1] if len(data) > 1 else None
367
+ elif msg_type_byte == CONTROL_CHANGE:
368
+ msg_types.add(MSG_CONTROL_CHANGE)
369
+ controller = data[1] if len(data) > 1 else None
370
+ elif msg_type_byte == PROGRAM_CHANGE:
371
+ msg_types.add(MSG_PROGRAM_CHANGE)
372
+ elif msg_type_byte == CHANNEL_PRESSURE:
373
+ msg_types.add(MSG_CHANNEL_PRESSURE)
374
+ elif msg_type_byte == PITCH_BEND:
375
+ msg_types.add(MSG_PITCH_BEND)
376
+ else:
377
+ # System message
378
+ msg_types.add(MSG_SYSTEM)
379
+
380
+ if status == SYSEX_START:
381
+ msg_types.add(MSG_SYSEX)
382
+ elif status == TIMING_CLOCK:
383
+ msg_types.add(MSG_TIMING_CLOCK)
384
+ msg_types.add(MSG_REALTIME)
385
+ elif status in (START, STOP, CONTINUE):
386
+ msg_types.add(MSG_TRANSPORT)
387
+ msg_types.add(MSG_REALTIME)
388
+ elif status == ACTIVE_SENSING:
389
+ msg_types.add(MSG_ACTIVE_SENSING)
390
+ msg_types.add(MSG_REALTIME)
391
+
392
+ # Check exclusions first
393
+ if self.exclude_types is not None:
394
+ if msg_types & self.exclude_types:
395
+ return False
396
+
397
+ if self.exclude_channels is not None and channel is not None:
398
+ if channel in self.exclude_channels:
399
+ return False
400
+
401
+ # Check inclusions
402
+ if self.types is not None:
403
+ if not (msg_types & self.types):
404
+ return False
405
+
406
+ if self.channels is not None and channel is not None:
407
+ if channel not in self.channels:
408
+ return False
409
+
410
+ if self.controllers is not None and MSG_CONTROL_CHANGE in msg_types:
411
+ if controller is None or controller not in self.controllers:
412
+ return False
413
+
414
+ if self.notes is not None and note is not None:
415
+ if note not in self.notes:
416
+ return False
417
+
418
+ return True
419
+
420
+ def filter_messages(self, messages: list) -> list:
421
+ """
422
+ Filter a list of MIDIMessage objects.
423
+
424
+ Args:
425
+ messages: List of MIDIMessage objects
426
+
427
+ Returns:
428
+ Filtered list containing only matching messages.
429
+ """
430
+ return [msg for msg in messages if self.matches(msg.data)]