Theater System Scripts

The theater control system includes several executable scripts. These may be individually browsed in Python/theater.

Brief summary:

lighting-server.py: Translates lighting commands from OSC messages to the DMX output device.

motion-server.py: Translates motion commands from OSC messages to the Arduino stepper motor drivers.

MIDI-bridge.py: Translates real-time MIDI events into OSC messages. This is used for linking MIDI-based performance tools to the core system.

Debugging tools:

MIDI-player.py: Translates a MIDI performance file into real-time OSC messages. This is primarily a means of testing a single performance.

set-lights.py: Maintenance utility to directly send commands to the DMX output device.

send-motion-command.py: Demonstration of sending a single OSC mesage to the motion control server.

Other Tools

The Performance Utility Tools may be individually browsed in Python/tools.

virtual-mpd218.py: Graphical simulation of a MIDI drum pad (requires PyQt6).

list-MIDI-ports.py: Console script to report available MIDI ports (requires rtmidi).

spline-plot.py: Graphicall plot a single-channel Bezier spline (required matplotlib).

print-MIDI-file.py: Display all events in a MIDI file (uses mido).

merge-MIDI-files.py: Sample script to merge a set of MIDI files (uses mido).

MIDI-player.py

Script to perform a MIDI files on the theater by translating MIDI events to OSC messages at the correct times. This uses the theater.midi module (Theater MIDI Processing) which you may have customized for use with midi-bridge.py. This ensures the interpretation is consistent between the two scripts.

Example use from your own laptop:

python3 MIDI-player.py --ip 172.24.26.199 performance.mid

The full code follows, but may also be downloaded from MIDI-player.py.

  1#!/usr/bin/env python3
  2
  3cmd_desc = "Replay a MIDI file in real time as system commands."
  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 MIDI event processing
 14from theater.midi import MIDItoOSC
 15
 16# common logging functions
 17import theater.logging
 18
 19# library schedule
 20import theater.HuntLibrary as HuntLibrary
 21
 22# initialize logging for this module
 23log = logging.getLogger('player')
 24
 25#================================================================
 26
 27class Player:
 28    """MIDI file player."""
 29
 30    def __init__(self, args):
 31
 32        self.verbose = args.verbose
 33
 34        self.midifile = mido.MidiFile(args.input)
 35        self.log_midifile_metadata()
 36
 37        # MIDI files express tempo as microseconds per quarter note; this sets
 38        # a default tempo of 120 BPM (0.5 sec per beat).
 39        self.midi_tempo = 500000
 40
 41        # MIDI files express times in integer ticks per quarter note.
 42        self.ticks_per_beat = self.midifile.ticks_per_beat
 43
 44        # Merge all tracks (if more than one) into a single track in time order.
 45        # This assumes that events are identified by channel number for routing
 46        # and not simply by track.
 47        self.playtrack = mido.merge_tracks(self.midifile.tracks)
 48
 49        # Create the networking output.
 50        self.bridge = MIDItoOSC(args)
 51
 52        return
 53
 54    def close(self):
 55        self.bridge.close()
 56
 57    #--------------------------------------------------------------------
 58    def log_midifile_metadata(self):
 59        log.info("Opened MIDI file %s, MIDI format %d, %d tracks, %d ticks per beat",
 60                 args.input, self.midifile.type, len(self.midifile.tracks), self.midifile.ticks_per_beat)
 61
 62        for i, track in enumerate(self.midifile.tracks):
 63            log.info("MIDI file track %d: %s", i, track.name)
 64
 65        # report some diagnostics on the current midifile to the log
 66        for track in self.midifile.tracks:
 67            log.info("Contents of track '%s'", track.name)
 68            event_messages = 0
 69            for msg in track:
 70                if msg.is_meta:
 71                    log.info("MIDI metadata: %s", msg)
 72                else:
 73                    event_messages += 1
 74            # end of track
 75            log.info("Track '%s' includes %d events.", track.name, event_messages)
 76
 77    #--------------------------------------------------------------------
 78    def perform_event(self, event):
 79        # ignore metadata messages track_name, instrument_name, key_signature, smpte_offset, etc.
 80        if event.type == 'set_tempo':
 81            log.info("MIDI tempo change: %d usec/beat (%f BPM).", event.tempo, mido.tempo2bpm(event.tempo))
 82            self.midi_tempo = event.tempo
 83
 84        elif event.type in ['note_on', 'note_off']:
 85            log.debug("Note On: channel %d, note %d, velocity %d", event.channel, event.note, event.velocity)
 86            self.bridge.perform_event(event)
 87
 88    #--------------------------------------------------------------------
 89    def run(self, skip=0):
 90        """Run one performance of the MIDI file and return.  Eeach MIDI event is
 91        issued in real time as OSC network messages translated via the MIDI to
 92        OSC bridge object."""
 93
 94        # Keep track of target times in integer nanoseconds to avoid roundoff
 95        # errors.  Following an absolute clock will maintain overall precision
 96        # in the presence of sleep time jitter.
 97        start_t = time.monotonic_ns()
 98        next_timepoint = start_t
 99
100        # play through all the events in the sequence, waiting the specified
101        # number of ticks before each issue
102        for event in self.playtrack:
103
104            if self.verbose:
105                log.debug("Playing event: %s", event)
106
107            # don't perform the end of track, it can have an unreasonable delay
108            if event.type == 'end_of_track':
109                log.info("Found end event: %s", event)
110                return
111
112            if skip > 0:
113                skip -= 1
114
115            else:
116                # if the next event has a preceding delay, convert from ticks
117                # at the current tempo to an absolute time in nanoseconds
118                if event.time > 0:
119                    event_ns = event.time * 1000 * self.midi_tempo / self.ticks_per_beat
120                    next_timepoint += event_ns
121                    delay_ns = next_timepoint - time.monotonic_ns()
122                    if delay_ns > 0:
123                        time.sleep(delay_ns * 1e-9)
124
125                self.perform_event(event)
126
127#================================================================
128# The following section is run when this is loaded as a script.
129if __name__ == "__main__":
130
131    # set up logging
132    theater.logging.open_log_file('logs/MIDI-player.log')
133    log.info("Starting MIDI-player.py")
134
135    # Initialize the command parser.
136    parser = argparse.ArgumentParser(description = cmd_desc)
137    parser.add_argument( '--ip', default="127.0.0.1",  help="IP address of the OSC receivers (default: %(default)s).")
138    parser.add_argument( '--scheduled',action='store_true',  help="Enable continuous operation on schedule.")
139    parser.add_argument( '--skip',type=int, default=0,  help="Specify number of events to skip.")
140    theater.logging.add_logging_args(parser)
141    parser.add_argument( 'input', help='MIDI file to perform.')
142
143    # Parse the command line, returning a Namespace.
144    args = parser.parse_args()
145
146    # Modify logging settings as per common arguments.
147    theater.logging.configure_logging(args)
148
149    # Create the MIDI event player.
150    player = Player(args)
151
152    # Run the performance until done. This may be safely interrupted by the user pressing Control-C.
153    try:
154
155        if args.scheduled:
156            # run the file repeatedly whenever the scheduler indicates show time
157            while True:
158                if HuntLibrary.current_time_properties().get('is_show_time'):
159                    log.info("Starting scheduled iteration.")
160                    player.run()
161                    log.info("Starting brief intermission pause.")
162                    time.sleep(5)
163                else:
164                    time.sleep(60) # wait before checking time again
165        else:
166            log.info("Starting a single performance.")
167            player.run(args.skip)
168
169    except KeyboardInterrupt:
170        log.info("User interrupted operation.")
171        print("User interrupt, shutting down.")
172        player.close()
173
174    log.info("Exiting MIDI-player.py")