Theater MIDI Processing¶
MIDI-show.py¶
Demonstration script for translating MIDI events into OSC messages. The events may be received in real time or loaded from a MIDI file. This is used for linking MIDI-based performance tools to the core system.
This script is intended to be customized as needed to define the mapping from MIDI events to lighting and motion splines.
It may be used in live mode while improvising and recording a show or streaming from a sequencer application. Once a show is complete and saved as a MIDI file, the same script can be used to play the show on demand for regular autonomous performances.
Example streaming use from your own laptop:
python3 MIDI-show.py --live
Example playback use from your own laptop:
python3 MIDI-show.py performance.mid
It is often handy to enable console output while developing the mapping algorithm:
python3 MIDI-show.py --live --console --debug
The full code follows, but may also be downloaded from MIDI-show.py.
1#!/usr/bin/env python3
2
3cmd_desc = "Translate MIDI stream or MIDI file to theater system commands in real time."
4
5import argparse
6import time
7import logging
8
9# Mido 'MIDI objects' package
10# https://mido.readthedocs.io/en/latest/index.html
11import mido
12
13# common logging functions
14import theater.logging
15
16# common MIDI data and file processing
17import theater.midi
18
19# networking
20from pythonosc import osc_message_builder
21from pythonosc import udp_client
22from theater.network import TheaterNetwork
23
24# initialize logging for this module
25log = logging.getLogger('MIDI')
26
27#================================================================
28# Predefined spline paths useful for MIDI cues. Each path implicitly begins with a
29# zero (not included). Every three points in each row specifies a Bezier spline
30# segment. The knots are specified in degrees of rotation relative to the
31# position at the start of the path.
32
33spline_table = [
34 [0, 0, -90, -90, 0, 0],
35 [0, 90, -90, -180, -180, -180, -180, 0, 0],
36 [0, -90, -90, -90, -180, -180, -180, -270, -270, -270, -360, -360],
37 [0, -60, -180, -300, -360, -360]
38 ]
39
40#================================================================
41# Predefined lighting levels useful for MIDI cues. Each
42# defines an array of fixture DMX levels.
43
44color_table = [
45 [ 0, 0, 255, 0], # RGBA fixture blue
46 [ 255, 0, 255, 0], # RGBA fixture purple
47 [ 0, 0, 0, 255], # RGBA fixture amber
48 [ 255, 255, 255, 255], # RGBA fixture all
49]
50
51
52#================================================================
53class MIDItoOSC:
54 """Manage translation of MIDI events into OSC messages for either the live MIDI stream processing or file playback."""
55
56 def __init__(self, args):
57
58 # Create the networking outputs.
59 self.network = TheaterNetwork(args)
60 self.num_motion_units = self.network.num_motion_units()
61 return
62
63 def close(self):
64 self.network.close()
65
66 #---------------------------------------------------------------
67 def perform_event(self, event):
68 """Interpret a mido MIDI event object and generate OSC messaging output."""
69
70 # The MPD218 drum pad emits events on MIDI channel 10, conventionally the
71 # general percussion channel. The mido library uses zero-based indexing,
72 # so the actual value of event.channel is 9. This case translates
73 # any direct drum pad events or sequencer percussion events into
74 # actions.
75 if event.channel == 9:
76 # log.warning("Received event on MIDI percussion channel 9, remapping to lighting.")
77 self.perform_lighting_event(event)
78
79 # log.warning("Received event on MIDI percussion channel 9, remapping to motion.")
80 # self.perform_motion_event(event, 0)
81
82 # Different sequencer tracks can be directed to different outputs by assigning
83 # one of the sixteen MIDI channels. This case translates MIDI channel 1
84 # (value 0) into lighting events.
85 elif event.channel == 0:
86 self.perform_lighting_event(event)
87
88 # Translate MIDI channels starting with 2 into motion unit events.
89 elif event.channel <= self.num_motion_units:
90 self.perform_motion_event(event, event.channel-1)
91
92 else:
93 log.warning("Ignoring event on channel %d", event.channel)
94
95 #---------------------------------------------------------------
96 def perform_lighting_event(self, event):
97 if event.type == 'note_on' or event.type == 'note_off':
98 row, col, bank = theater.midi.decode_mpd218_key(event.note)
99
100 if row == 0 or row == 1:
101 # the bottom two rows on the MPD218 drum pad are mapped to
102 # individual control of mono lighting channels
103 channel = 4*row + col
104 if event.type == 'note_on':
105 level = event.velocity*255.0/127.0
106 else:
107 level = 0
108 self.network.lights.send_message("/fixture", [channel, level])
109 log.debug("Sending lighting /fixture %d with level %f", channel, level)
110
111 elif row == 2 or row == 3:
112 # the upper two rows of the MPD218 are mapped to footlight colors
113 if event.type == 'note_on':
114 color = color_table[row-2]
115 else:
116 color = [0, 0, 0, 0]
117
118 if color is not None:
119 fixture = col + 8
120 scaling = event.velocity / 127.0
121 values = [fixture] + [value*scaling for value in color]
122 log.debug("Sending lighting /fixture %s", values)
123 self.network.lights.send_message("/fixture", values)
124
125 else:
126 log.debug("ignoring note on %d, %d", event.note, event.velocity)
127
128 elif event.type == 'pitchwheel':
129 pass
130
131 elif event.type == 'aftertouch':
132 # An Akai MPD218 drum pad in most default modes will emit a
133 # continuous stream of aftertouch messages for each pad held down. To
134 # enable: hold Prog Select, tap Pad 1. But in general this stream
135 # can overwhelm the lighting system so by default it is ignored.
136 pass
137
138 elif event.type == 'polytouch':
139 # An Akai MPD218 drum pad set to Program 8 will emit a continuous
140 # stream of polyphonic aftertouch messages for each pad held down.
141 # To enable: hold Prog Select, tap Pad 8. But in general this
142 # stream can overwhelm the lighting system so by default it is
143 # ignored.
144 pass
145
146 else:
147 log.debug("Ignoring lighting MIDI event %s", event)
148
149 #================================================================
150 def perform_motion_event(self, event, unit):
151 if event.type in ['note_off', 'aftertouch', 'polytouch', 'pitchwheel', 'control_change']:
152 pass
153
154 elif event.type == 'note_on':
155 # translate the MIDI event into a OSC /spline message
156 row, col, bank = theater.midi.decode_mpd218_key(event.note)
157 channel = col
158 path = row
159 args = [channel] + spline_table[path]
160 self.network.motion_server(unit).send_message("/spline", args)
161 log.debug("Sending motion command MIDI (%d, %d) to unit %d as /spline %s", event.note, event.velocity, unit, args)
162
163 else:
164 log.debug("Ignoring motion MIDI event %s", event)
165
166#================================================================
167# The following section is run when this is loaded as a script.
168if __name__ == "__main__":
169
170 # set up logging
171 theater.logging.open_log_file('logs/MIDI-show.log')
172 log.info("Starting MIDI-show.py")
173
174 # Initialize the command parser.
175 parser = argparse.ArgumentParser(description = cmd_desc)
176 parser.add_argument( '--live', action="store_true", help='Enable live MIDI input processing.')
177 parser.add_argument( '--endpoint', default='IAC Driver Bus 1', help='MIDI input endpoint (default: %(default)s).')
178 # parser.add_argument( '--ip', default="127.0.0.1", help="IP address of the OSC receivers (default: %(default)s).")
179 parser.add_argument("--ip", default=theater.config.theater_IP, help="IP address of the OSC receivers (default: %(default)s).")
180 parser.add_argument( '--skip',type=int, default=0, help="Number of playback events to skip.")
181 theater.logging.add_logging_args(parser)
182 parser.add_argument( 'midifile', nargs="?", help='Optional MIDI file to perform.')
183
184 # Parse the command line, returning a Namespace.
185 args = parser.parse_args()
186
187 # Modify logging settings as per common arguments.
188 theater.logging.configure_logging(args)
189
190 # Create the networking output.
191 bridge = MIDItoOSC(args)
192
193 # Create the MIDI receiver.
194 if args.live:
195 receiver = theater.midi.Receiver(args, args.endpoint, bridge.perform_event)
196 else:
197 receiver = None
198
199 # Create the MIDI event player.
200 if args.midifile is not None:
201 player = theater.midi.Player(args, args.midifile, bridge.perform_event)
202 else:
203 player = None
204
205 # If neither a receiver or player was created, there is nothing to do.
206 if receiver is None and player is None:
207 print("Nothing to do; please try enabling the MIDI receiver or providing a MIDI file to play.")
208 log.debug("Nothing to do, no player or receiver.")
209 bridge.close()
210
211 else:
212 # Run a single playback or enter a continuous stream processing mode. This may be safely interrupted by the user pressing Control-C.
213 try:
214 if player is not None:
215 log.info("Starting a single performance.")
216 player.run(args.skip)
217 else:
218 while True:
219 time.sleep(20)
220 log.debug("still running")
221
222 except KeyboardInterrupt:
223 log.info("User interrupted operation.")
224 print("User interrupt, shutting down.")
225 if player is not None:
226 player.close()
227
228 if receiver is not None:
229 receiver.close()
230
231 bridge.close()
232
233 except Exception as e:
234 log.error("unable to continue: %s: %s", type(e), e)
235 print("unable to continue: ", type(e), e)
236
237 log.info("Exiting MIDI-show.py")
theater.midi module¶
The MIDI-show.py script uses the following module to receive a real time MIDI event stream or play events from a MIDI file.
midi.py
MIDI event processing objects common to several scripts. Uses the mido library for MIDI file and endpoint I/O.
- class theater.midi.Receiver(args, midi_endpoint, event_callback)[source]¶
Use the mido library to create a MIDI endpoint. Real-time events are received on a background thread and passed to a user-supplied callback.
- theater.midi.decode_mpd218_key(key)[source]¶
Interpret a MPD218 pad event note value as a row, column, and bank position. Row 0 is the front/bottom row (Pads 1-4), row 3 is the back/top row (Pads 13-16). Column 0 is the left, column 3 is the right. Bank 0 is the A bank, bank 1 is the B bank, bank 2 is the C bank.
- Parameters:
key – an integer MIDI note value
- Returns:
(row, column, bank)