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 stage.logconfig
 15
 16# common MIDI data and file processing
 17import stage.midi
 18
 19# networking
 20from pythonosc import osc_message_builder
 21from pythonosc import udp_client
 22from stage.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 = stage.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 = stage.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    stage.logconfig.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=stage.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    stage.logconfig.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    stage.logconfig.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 = stage.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 = stage.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")

stage.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 stage.midi.Player(args, filename, callback)[source]

MIDI file player.

close()[source]
log_midifile_metadata(filename, midifile)[source]
perform_event(event)[source]
run(skip=0)[source]

Run one performance of the MIDI file and return. Eeach MIDI event is issued in real time as OSC network messages translated via the MIDI to OSC bridge object.

class stage.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.

close()[source]
stage.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)