#!/usr/bin/env python3
"""A PyQt5 GUI to simulate an Akai MPD218 MIDI controller.  The actual controller
has pressure-sensitive pads, so the simulation is approximate, this is only
intended for testing offline without the physical controller.
"""
################################################################
# Written in 2018 by Garth Zeglin <garthz@cmu.edu>

# To the extent possible under law, the author has dedicated all copyright
# and related and neighboring rights to this software to the public domain
# worldwide. This software is distributed without any warranty.

# You should have received a copy of the CC0 Public Domain Dedication along with this software.
# If not, see <http://creativecommons.org/publicdomain/zero/1.0/>.

################################################################
# standard Python libraries
from __future__ import print_function
import os, sys, struct, time, logging, functools, platform, argparse

# for documentation on the PyQt5 API, see http://pyqt.sourceforge.net/Docs/PyQt5/index.html
from PyQt5 import QtCore, QtGui, QtWidgets

# for documentation on python-rtmidi: https://pypi.org/project/python-rtmidi/
import rtmidi

# set up logger for module
log = logging.getLogger('virtual_mpd218')

################################################################
class ButtonBox(QtWidgets.QMainWindow):
    """A custom main window which provides all GUI controls."""

    def __init__( self, *args, **kwargs):
        super(ButtonBox,self).__init__()

        # create the GUI elements
        self.setupUi()
        
        # finish initialization
        self.show()

        # start the channel pressuretimer
        self.timer = QtCore.QTimer()
        self.timer.start(100)  # units are milliseconds
        self.timer.timeout.connect(self.timer_tick)
        return

    # ------------------------------------------------------------------------------------------------
    def setupUi(self):
        self.setWindowTitle("Virtual MPD218 MIDI Controller")
        self.resize(500, 400)

        self.centralwidget = QtWidgets.QWidget(self)
        self.setCentralWidget(self.centralwidget)

        self.mainLayout = QtWidgets.QHBoxLayout(self.centralwidget)
        self.mainLayout.setContentsMargins(-1, -1, -1, 9)

        # generate an array of dial widgets
        self.dialGrid = QtWidgets.QGridLayout()
        self.dials = list()
        for i in range(6):
            row = i // 2
            col = i % 2
            dial = QtWidgets.QDial(self.centralwidget)
            dial.setMinimumSize(QtCore.QSize(0, 30))
            dial.setMaximum(127)
            self.dialGrid.addWidget(dial, row, col, 1, 1)
            dial.valueChanged['int'].connect(functools.partial(self.dialMoved, i))
            self.dials.append(dial)

        # add the bank selects at the bottom of the dial grid
        self.controlBank = QtWidgets.QComboBox()
        self.padBank     = QtWidgets.QComboBox()
        for item in ["A", "B", "C"]:
            self.controlBank.addItem(item)
            self.padBank.addItem(item)

        self.controlLabel = QtWidgets.QLabel()
        self.controlLabel.setText("CTRL BANK")
        self.padLabel = QtWidgets.QLabel()
        self.padLabel.setText("PAD BANK")
        self.dialGrid.addWidget(self.controlBank, 3, 0, 1, 1)
        self.dialGrid.addWidget(self.padBank, 3, 1, 1, 1)
        self.dialGrid.addWidget(self.controlLabel, 4, 0, 1, 1)
        self.dialGrid.addWidget(self.padLabel, 4, 1, 1, 1)

        self.mainLayout.addLayout(self.dialGrid)

        # generate a grid of buttons to represent the pads
        self.buttonGrid = QtWidgets.QGridLayout()
        self.pushbuttons = list()
        columns = 4
        for button in range(16):
            row = 3 - (button // columns)
            col = button % columns
            title = str(button+1)
            pushButton = QtWidgets.QPushButton(self.centralwidget)
            pushButton.setMinimumSize(QtCore.QSize(80, 80))
            self.buttonGrid.addWidget(pushButton, row, col, 1, 1)
            pushButton.setText(title)
            pushButton.pressed.connect(functools.partial(self.buttonPressed, button))
            pushButton.released.connect(functools.partial(self.buttonReleased, button))
            self.pushbuttons.append(pushButton)

        self.mainLayout.addLayout(self.buttonGrid)


        self.statusbar = QtWidgets.QStatusBar(self)
        self.setStatusBar(self.statusbar)


        # set up the main menu
        if False:
            self.actionQuit = QtWidgets.QAction(self)
            self.actionQuit.setText("Quit")
            self.actionQuit.setShortcut("Ctrl+Q")
            self.actionQuit.triggered.connect(self.quitSelected)
            self.menubar = QtWidgets.QMenuBar(self)
            self.menubar.setGeometry(QtCore.QRect(0, 0, 500, 22))
            self.menubar.setNativeMenuBar(False)
            self.menubar.setObjectName("menubar")
            self.menuTitle = QtWidgets.QMenu(self.menubar)
            self.setMenuBar(self.menubar)
            self.menuTitle.addAction(self.actionQuit)
            self.menubar.addAction(self.menuTitle.menuAction())
            self.menuTitle.setTitle("File")

        return

    # --- window and qt event processing -------------------------------------------------------------
    def show_status(self, string):
        self.statusbar.showMessage(string)

    def write(self, string):
        """Write output to the status bar."""
        self.statusbar.showMessage(string)
        return

    def quitSelected(self):
        self.write("User selected quit.")
        self.close()

    def closeEvent(self, event):
        self.write("Received window close event.")
        super(ButtonBox,self).closeEvent(event)

    # --------------------------------------------------------------------------------------------------
    def buttonPressed(self, button):
        bankname = self.padBank.currentText()        
        self.write("Pad %d on bank %s pressed." % (button+1, bankname))
        bank = self.padBank.currentIndex()
        self.main.send_note_on(36 + button + 16*bank)

    def buttonReleased(self, button):
        bankname = self.padBank.currentText()
        self.write("Pad %d on bank %s released." % (button+1, bankname))
        bank = self.padBank.currentIndex()
        self.main.send_note_off(36 + button + 16*bank)

    def dialMoved(self, dial, value):
        bankname = self.controlBank.currentText()
        self.write("Dial %d on bank %s moved to %d" % (dial+1, bankname, value))

        # the MPD218 has a non-contiguous controller channel mapping
        bank = self.controlBank.currentIndex()        
        if bank == 0:
            cc = (3, 9, 12, 13, 14, 15)[dial]
        else:
            cc = 16 + dial + 6 * (bank-1)
        self.main.send_controller_change(cc, value)

    # The MPD218 delivers channel pressure events as long as any pad is pressed.
    def timer_tick(self):
        if any((button.isDown() for button in self.pushbuttons)):
            self.main.send_channel_pressure(64)
            
################################################################
class MainApp(object):
    """Main application object holding any non-GUI related state."""

    def __init__(self):
        # create the interface window
        self.window = ButtonBox()
        self.window.main = self

        # global state
        self.midiout = rtmidi.MidiOut()
        if platform.system() == 'Windows':
            for idx, name in enumerate(self.midiout.get_ports()):
                if 'loopMIDI' in name:
                    log.info("Found loop MIDI input device %d: %s" % (idx, name))
                    self.midiout.open_port(idx)
                    self.window.show_status("Opened MIDI output %s." % name)
                    break
            if not self.midiout.is_port_open():
                self.window.show_status("Virtual MIDI inputs are not currently supported on Windows, see python-rtmidi documentation.")
        else:
            self.midiout.open_virtual_port("virtual MPD218")
            self.window.show_status("Ready to go, sending to virtual MIDI port 'virtual MPD218'...")
            log.info("Opened virtual MIDI input port.")

        return

    # The MPD218 by default sends to MIDI channel 10, the General MIDI percussion channel.
    # 0x99 == 0x90 | 0x09, 0x90 == Note On,           0x09 == channel 10
    # 0x89 == 0x80 | 0x09, 0x80 == Note Off,          0x09 == channel 10
    # 0xb0 == 0xb0 | 0x09, 0x00 == Controller Change, 0x09 == channel  1
    # 0xd9 == 0xd0 | 0x09, 0xd0 == Channel Pressure,  0x09 == channel 10
    def send_note_on(self, note):
        log.debug("Note On: %d", note)
        self.midiout.send_message([0x99, note, 127])

    def send_note_off(self, note):
        log.debug("Note Off: %d", note)
        self.midiout.send_message([0x89, note, 0])

    def send_controller_change(self, number, value):
        log.debug("CC: %d %d", number, value)
        self.midiout.send_message([0xb0, number, value])

    def send_channel_pressure(self, value):
        log.debug("CP: %d", value)
        self.midiout.send_message([0xd9, value])
    

################################################################

def main():
    # initialize the Qt system itself
    app = QtWidgets.QApplication(sys.argv)

    # create the main application controller
    main = MainApp()

    # run the event loop until the user is done
    log.info("Starting event loop.")
    sys.exit(app.exec_())

################################################################
# Main script follows.  This sequence is executed when the script is initiated from the command line.

if __name__ == "__main__":
    parser = argparse.ArgumentParser()
    parser.add_argument('--debug', action='store_true', help='Enable debugging logging to console.')
    args = parser.parse_args()

    if args.debug:
        console_handler = logging.StreamHandler()
        console_handler.setLevel(logging.DEBUG)
        console_handler.setFormatter(logging.Formatter('%(levelname)s:%(name)s: %(message)s'))
        logging.getLogger().addHandler(console_handler)
        logging.getLogger().setLevel(logging.DEBUG)

    main()
