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