#!/usr/bin/env python3

cmd_desc = "Translate MIDI stream or MIDI file to theater system commands in real time."

import argparse
import time
import logging

# Mido 'MIDI objects' package
# https://mido.readthedocs.io/en/latest/index.html
import mido

# common logging functions
import stage.logconfig

# common MIDI data and file processing
import stage.midi

# networking
from pythonosc import osc_message_builder
from pythonosc import udp_client
from stage.network import TheaterNetwork

# initialize logging for this module
log = logging.getLogger('MIDI')

#================================================================
# Predefined spline paths useful for MIDI cues.  Each path implicitly begins with a
# zero (not included).  Every three points in each row specifies a Bezier spline
# segment.  The knots are specified in degrees of rotation relative to the
# position at the start of the path.

spline_table = [
    [0,   0,  -90,    -90, 0, 0],
    [0,  90,  -90,   -180, -180, -180,   -180,    0,   0],
    [0, -90,  -90,    -90, -180, -180,   -180, -270, -270,   -270, -360, -360],
    [0, -60,  -180,  -300, -360, -360]
    ]

#================================================================
# Predefined lighting levels useful for MIDI cues.  Each
# defines an array of fixture DMX levels.

color_table = [
    [   0,   0, 255,   0],  # RGBA fixture blue
    [ 255,   0, 255,   0],  # RGBA fixture purple
    [   0,   0,   0, 255],  # RGBA fixture amber
    [ 255, 255, 255, 255],  # RGBA fixture all
]


#================================================================
class MIDItoOSC:
    """Manage translation of MIDI events into OSC messages for either the live MIDI stream processing or file playback."""

    def __init__(self, args):

        # Create the networking outputs.
        self.network = TheaterNetwork(args)
        self.num_motion_units = self.network.num_motion_units()
        return

    def close(self):
        self.network.close()

    #---------------------------------------------------------------
    def perform_event(self, event):
        """Interpret a mido MIDI event object and generate OSC messaging output."""

        # The MPD218 drum pad emits events on MIDI channel 10, conventionally the
        # general percussion channel.  The mido library uses zero-based indexing,
        # so the actual value of event.channel is 9.  This case translates
        # any direct drum pad events or sequencer percussion events into
        # actions.
        if event.channel == 9:
            # log.warning("Received event on MIDI percussion channel 9, remapping to lighting.")
            self.perform_lighting_event(event)

            # log.warning("Received event on MIDI percussion channel 9, remapping to motion.")
            # self.perform_motion_event(event, 0)

        # Different sequencer tracks can be directed to different outputs by assigning
        # one of the sixteen MIDI channels.  This case translates MIDI channel 1
        # (value 0) into lighting events.
        elif event.channel == 0:
            self.perform_lighting_event(event)

        # Translate MIDI channels starting with 2 into motion unit events.
        elif event.channel <= self.num_motion_units:
            self.perform_motion_event(event, event.channel-1)

        else:
            log.warning("Ignoring event on channel %d", event.channel)

    #---------------------------------------------------------------
    def perform_lighting_event(self, event):
        if event.type == 'note_on' or event.type == 'note_off':
            row, col, bank = stage.midi.decode_mpd218_key(event.note)

            if row == 0 or row == 1:
                # the bottom two rows on the MPD218 drum pad are mapped to
                # individual control of mono lighting channels
                channel = 4*row + col
                if event.type == 'note_on':
                    level = event.velocity*255.0/127.0
                else:
                    level = 0
                self.network.lights.send_message("/fixture", [channel, level])
                log.debug("Sending lighting /fixture %d with level %f", channel, level)

            elif row == 2 or row == 3:
                # the upper two rows of the MPD218 are mapped to footlight colors
                if event.type == 'note_on':
                    color = color_table[row-2]
                else:
                    color = [0, 0, 0, 0]

                if color is not None:
                    fixture = col + 8
                    scaling = event.velocity / 127.0
                    values = [fixture] + [value*scaling for value in color]
                    log.debug("Sending lighting /fixture %s", values)
                    self.network.lights.send_message("/fixture", values)

            else:
                log.debug("ignoring note on %d, %d", event.note, event.velocity)

        elif event.type == 'pitchwheel':
            pass

        elif event.type == 'aftertouch':
            # An Akai MPD218 drum pad in most default modes will emit a
            # continuous stream of aftertouch messages for each pad held down.  To
            # enable: hold Prog Select, tap Pad 1.  But in general this stream
            # can overwhelm the lighting system so by default it is ignored.
            pass

        elif event.type == 'polytouch':
            # An Akai MPD218 drum pad set to Program 8 will emit a continuous
            # stream of polyphonic aftertouch messages for each pad held down.
            # To enable: hold Prog Select, tap Pad 8.  But in general this
            # stream can overwhelm the lighting system so by default it is
            # ignored.
            pass

        else:
            log.debug("Ignoring lighting MIDI event %s", event)

    #================================================================
    def perform_motion_event(self, event, unit):
        if event.type in ['note_off', 'aftertouch', 'polytouch', 'pitchwheel', 'control_change']:
            pass

        elif event.type == 'note_on':
            # translate the MIDI event into a OSC /spline message
            row, col, bank = stage.midi.decode_mpd218_key(event.note)
            channel = col
            path    = row
            args = [channel] + spline_table[path]
            self.network.motion_server(unit).send_message("/spline", args)
            log.debug("Sending motion command MIDI (%d, %d) to unit %d as /spline %s", event.note, event.velocity, unit, args)

        else:
            log.debug("Ignoring motion MIDI event %s", event)

#================================================================
# The following section is run when this is loaded as a script.
if __name__ == "__main__":

    # set up logging
    stage.logconfig.open_log_file('logs/MIDI-show.log')
    log.info("Starting MIDI-show.py")

    # Initialize the command parser.
    parser = argparse.ArgumentParser(description = cmd_desc)
    parser.add_argument( '--live', action="store_true", help='Enable live MIDI input processing.')
    parser.add_argument( '--endpoint', default='IAC Driver Bus 1', help='MIDI input endpoint (default: %(default)s).')
    # parser.add_argument( '--ip', default="127.0.0.1",  help="IP address of the OSC receivers (default: %(default)s).")
    parser.add_argument("--ip", default=stage.config.theater_IP,  help="IP address of the OSC receivers (default: %(default)s).")
    parser.add_argument( '--skip',type=int, default=0,  help="Number of playback events to skip.")
    stage.logconfig.add_logging_args(parser)
    parser.add_argument( 'midifile', nargs="?", help='Optional MIDI file to perform.')

    # Parse the command line, returning a Namespace.
    args = parser.parse_args()

    # Modify logging settings as per common arguments.
    stage.logconfig.configure_logging(args)

    # Create the networking output.
    bridge = MIDItoOSC(args)

    # Create the MIDI receiver.
    if args.live:
        receiver = stage.midi.Receiver(args, args.endpoint, bridge.perform_event)
    else:
        receiver = None

    # Create the MIDI event player.
    if args.midifile is not None:
        player = stage.midi.Player(args, args.midifile, bridge.perform_event)
    else:
        player = None

    # If neither a receiver or player was created, there is nothing to do.
    if receiver is None and player is None:
        print("Nothing to do; please try enabling the MIDI receiver or providing a MIDI file to play.")
        log.debug("Nothing to do, no player or receiver.")
        bridge.close()

    else:
        # Run a single playback or enter a continuous stream processing mode. This may be safely interrupted by the user pressing Control-C.
        try:
            if player is not None:
                log.info("Starting a single performance.")
                player.run(args.skip)
            else:
                while True:
                    time.sleep(20)
                    log.debug("still running")

        except KeyboardInterrupt:
            log.info("User interrupted operation.")
            print("User interrupt, shutting down.")
            if player is not None:
                player.close()

            if receiver is not None:
                receiver.close()

            bridge.close()

        except Exception as e:
            log.error("unable to continue: %s: %s", type(e), e)
            print("unable to continue: ", type(e), e)

    log.info("Exiting MIDI-show.py")
