# $Id$
#
# Author:    Filippo Rivato
# Date:      20 December 2015
# Porting of midi2cnc https://github.com/michthom/MIDI-to-CNC (GPLv2+)

import math

from CNC import CNC, Block
from ToolsPage import Plugin

__author__ = "Filippo Rivato"
__email__ = "f.rivato@gmail.com"

__name__ = "Midi2CNC"
__version__ = "0.0.1"


# =============================================================================
# Midi2CNC class
# =============================================================================
class Midi2CNC:
    def __init__(self, name="Midi2CNC"):
        self.name = name


# =============================================================================
# Create pyrograph
# =============================================================================
class Tool(Plugin):
    __doc__ = _("Sound your machine from a midi file")

    def __init__(self, master):
        Plugin.__init__(self, master, "Midi2CNC")
        self.icon = "midi2cnc"
        self.group = "Artistic"

        self.axes_dict = dict(
            {
                "X": [0],
                "Y": [1],
                "Z": [2],
                "XY": [0, 1],
                "YX": [1, 0],
                "XZ": [0, 2],
                "ZX": [2, 0],
                "YZ": [1, 2],
                "ZY": [2, 1],
                "XYZ": [0, 1, 2],
                "XZY": [0, 2, 1],
                "YXZ": [1, 0, 2],
                "YZX": [1, 2, 0],
                "ZXY": [2, 0, 1],
                "ZYX": [2, 1, 0],
            }
        )

        self.variables = [
            ("name", "db", "", _("Name")),
            ("ppu_X", "float", 200.0, _("Pulse per unit for X")),
            ("ppu_Y", "float", 200.0, _("Pulse per unit for Y")),
            ("ppu_Z", "float", 200.0, _("Pulse per unit for Z")),
            ("max_X", "int", 50, _("Maximum X travel")),
            ("max_Y", "int", 50, _("Maximum Y travel")),
            ("max_Z", "int", 20, _("Maximum Z travel")),
            ("AxisUsed",
             ",".join(self.axes_dict.keys()),
             "XYZ",
             _("Axis to be used")),
            ("File", "file", "", _("Midi to process")),
        ]
        self.buttons.append("exe")

    # ----------------------------------------------------------------------
    def reached_limit(self, current, distance, direction, min_, max_):
        # Returns true if the proposed movement will exceed the
        # safe working limits of the machine but the movement is
        # allowable in the reverse direction
        #
        # Returns false if the movement is allowable in the
        # current direction
        #
        # Aborts if the movement is not possible in either direction

        if ((current + (distance * direction)) < max_) and (
            (current + (distance * direction)) > min_
        ):
            # Movement in the current direction is within safe limits,
            return False

        elif ((current + (distance * direction)) >= max_) and (
            (current - (distance * direction)) > min_
        ):
            # Movement in the current direction violates maximum safe
            # value, but would be safe if the direction is reversed
            return True

        elif ((current + (distance * direction)) <= min_) and (
            (current - (distance * direction)) < max_
        ):
            # Movement in the current direction violates minimum safe
            # value, but would be safe if the direction is reversed
            return True

        else:
            # Movement in *either* direction violates the safe working
            # envelope, so abort.
            exit(2)

    # ----------------------------------------------------------------------
    def execute(self, app):
        try:
            import midiparser
        except Exception:
            app.setStatus(_("Error: This plugin requires midiparser.py"))
            return

        n = self["name"]
        if not n or n == "default":
            n = "Midi2CNC"

        fileName = self["File"]

        x = 0.0
        y = 0.0
        z = 0.0

        x_dir = 1.0
        y_dir = 1.0
        z_dir = 1.0

        # List of MIDI channels (instruments) to import.
        # Channel 10 is percussion, so better to omit it
        channels = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]

        axes = self["AxisUsed"]
        active_axes = len(axes)

        transpose = (0, 0, 0)
        ppu = [200, 200, 200]
        ppu[0] = self["ppu_X"]
        ppu[1] = self["ppu_X"]
        ppu[2] = self["ppu_X"]

        safemin = [0, 0, 0]
        safemax = [100, 100, 50]
        safemax[0] = self["max_X"]
        safemax[1] = self["max_Y"]
        safemax[2] = self["max_Z"]

        try:
            midi = midiparser.File(fileName)
        except Exception:
            app.setStatus(_("Error: Sorry can't parse the Midi file."))
            return

        noteEventList = []
        all_channels = set()

        for track in midi.tracks:
            for event in track.events:
                if event.type == midiparser.meta.SetTempo:
                    tempo = event.detail.tempo

                # filter undesired instruments
                if (event.type == midiparser.voice.NoteOn) and (
                    event.channel in channels
                ):

                    if event.channel not in channels:
                        channels.add(event.channel)

                    # NB: looks like some use "note on (vel 0)" as equivalent
                    # to note off, so check for vel=0 here and treat it
                    # as a note-off.
                    if event.detail.velocity > 0:
                        noteEventList.append(
                            [
                                event.absolute,
                                1,
                                event.detail.note_no,
                                event.detail.velocity,
                            ]
                        )
                    else:
                        noteEventList.append(
                            [
                                event.absolute,
                                0,
                                event.detail.note_no,
                                event.detail.velocity,
                            ]
                        )

                if (event.type == midiparser.voice.NoteOff) and (
                    event.channel in channels
                ):
                    if event.channel not in channels:
                        channels.add(event.channel)
                    noteEventList.append([event.absolute,
                                          0,
                                          event.detail.note_no,
                                          event.detail.velocity]
                                         )

            # Finished with this track
            if len(channels) > 0:
                all_channels = all_channels.union(channels)

        # We now have entire file's notes with abs time from all channels
        # We don't care which channel/voice is which, but we do care about
        # having all the notes in order so sort event list by abstime
        # to dechannelify

        noteEventList.sort()

        last_time = -0
        # make this a dict so we can add and remove notes by name
        active_notes = {}

        # Start the output
        # Init blocks
        blocks = []
        block = Block(self.name)
        block.append("(Midi2CNC)")
        block.append(f"(Midi:{fileName})")
        block.append(CNC.zsafe())
        block.append(CNC.grapid(0, 0))
        block.append(CNC.zenter(0))

        for note in noteEventList:
            if last_time < note[0]:

                freq_xyz = [0, 0, 0]
                feed_xyz = [0, 0, 0]
                distance_xyz = [0, 0, 0]
                duration = 0

                # "i" ranges from 0 to "the number of active notes *or* the number of active axes,
                # whichever is LOWER". Note that the range operator stops
                # short of the maximum, so this means 0 to 2 at most for a 3-axis machine.
                # E.g. only look for the first few active notes to play despite what
                # is going on in the actual score.

                for i in range(0, min(len(active_notes.values()),
                                      active_axes)):

                    # Which axis are should we be writing to?
                    #
                    j = self.axes_dict.get(axes)[i]

                    # Sound higher pitched notes first by sorting by pitch
                    # then indexing by axis
                    #
                    nownote = sorted(active_notes.values(), reverse=True)[i]

                    # MIDI note 69   = A4(440Hz)
                    # 2 to the power (69-69) / 12 * 440 = A4 440Hz
                    # 2 to the power (64-69) / 12 * 440 = E4 329.627Hz
                    #
                    freq_xyz[j] = pow(
                        2.0, (nownote - 69 + transpose[j]) / 12.0) * 440.0

                    # Here is where we need smart per-axis feed conversions
                    # to enable use of X/Y *and* Z on a Makerbot
                    #
                    # feed_xyz[0] = X; feed_xyz[1] = Y; feed_xyz[2] = Z;
                    #
                    # Feed rate is expressed in mm / minutes so 60 times
                    # scaling factor is required.

                    feed_xyz[j] = (freq_xyz[j] * 60.0) / ppu[j]

                    # Get the duration in seconds from the MIDI values in
                    # divisions, at the given tempo
                    duration = (
                        ((note[0] - last_time) + 0.0)
                        / (midi.division + 0.0)
                        * (tempo / 1000000.0)
                    )

                    # Get the actual relative distance travelled per axis in mm
                    distance_xyz[j] = (feed_xyz[j] * duration) / 60.0

                # Now that axes can be addressed in any order, need to make
                # sure that all of them are silent before declaring a rest
                # is due.
                if distance_xyz[0] + distance_xyz[1] + distance_xyz[2] > 0:
                    # At least one axis is playing, so process the note into
                    # movements
                    combined_feedrate = math.sqrt(
                        feed_xyz[0] ** 2 + feed_xyz[1] ** 2 + feed_xyz[2] ** 2
                    )

                    # Turn around BEFORE crossing the limits of the
                    # safe working envelope
                    if self.reached_limit(
                        x, distance_xyz[0], x_dir, safemin[0], safemax[0]
                    ):
                        x_dir = x_dir * -1
                    x = x + (distance_xyz[0] * x_dir)

                    if self.reached_limit(
                        y, distance_xyz[1], y_dir, safemin[1], safemax[1]
                    ):
                        y_dir = y_dir * -1
                    y = y + (distance_xyz[1] * y_dir)

                    if self.reached_limit(
                        z, distance_xyz[2], z_dir, safemin[2], safemax[2]
                    ):
                        z_dir = z_dir * -1
                    z = z + (distance_xyz[2] * z_dir)

                    v = (x, y, z)
                    block.append(CNC.glinev(1, v, combined_feedrate))

                else:
                    # Handle 'rests' in addition to notes.
                    duration = (
                        ((note[0] - last_time) + 0.0) / (midi.division + 0.0)
                    ) * (tempo / 1000000.0)
                    block.append(CNC.gcode(4, [("P", duration)]))

                # finally, set this absolute time as the new starting time
                last_time = note[0]

            if note[1] == 1:  # Note on
                if note[2] in active_notes:
                    pass
                else:
                    # key and value are the same, but we don't really care.
                    active_notes[note[2]] = note[2]
            elif note[1] == 0:  # Note off
                if note[2] in active_notes:
                    active_notes.pop(note[2])

        blocks.append(block)
        active = app.activeBlock()
        if active == 0:
            active = 1
        app.gcode.insBlocks(active, blocks, "Midi2CNC")
        app.refresh()
        app.setStatus(_("Generated Midi2CNC, ready to play?"))
