A small proof-of-concept test app for controlling the Neural DSP Quad Cortex with MIDI

The application is a Python script with a user interface built using Qt5. It is platform-independent and has been tested to work on macOS, Windows, and Linux (potentially with minor modifications depending on the case).

The PoC App Qt5 UI on macOS

Prerequisites (in this example for macOS)

  1. Install homebrew: /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
  2. Install Python: brew install python
  3. Install Dependencies: pip3 install pyqt5 mido python-rtmidi

The actual script

# Neural DSP Quad Cortex MIDI Controller
# Created by hartsa.fi on May 2024
# Updated for CorOS 3.1.0 on Dec 2024

from PyQt5.QtWidgets import QApplication, QWidget, QPushButton, QVBoxLayout, QHBoxLayout, \
    QTextEdit, QMessageBox, QLabel
from PyQt5.QtGui import QIcon, QPixmap, QFont
import sys, os
import mido  # Import mido for handling MIDI operations

class App(QWidget):
    # Define MIDI CC numbers and values as class variables
    MIDI_CC = {
        'TOGGLE_GIG_VIEW': {'control': 46, 'values': {'OFF': 63, 'ON': 64}},
        'TOGGLE_TUNER': {'control': 45, 'values': {'OFF': 63, 'ON': 64}},
        'TOGGLE_LOOPER': {'control': 48, 'values': {'OFF': 127, 'ON': 0}},
        'TAP_TEMPO': {'control': 44, 'values': {'PRESS': 127}}
    }

    def __init__(self):
        super().__init__()
        self.device_name = "Quad Cortex"  # Initialize the name of the MIDI device
        self.tuner_state = 63  # Initial state for tuner (OFF)
        self.gig_view_state = 63  # Initial state for gig view (OFF)
        self.looper_state = 127  # Initial state for looper (OFF)
        self.output_ports = mido.get_output_names()  # Retrieve all available MIDI output ports
        self.initUI()  # Setup the user interface
        self.midi_port = None  # Variable to hold the active MIDI port connection
        self.find_midi_device()  # Connect to the MIDI device on startup

    def initUI(self):
        self.setWindowTitle('QC MIDI Controller')
        mainLayout = QVBoxLayout(self)  # Main vertical layout

        # Determine the current user's home directory
        home_dir = os.path.expanduser('~')

        # Determine the path to the logo image based on the operating system
        icon_path = os.path.join(home_dir, 'QC', 'icon.png')  # Logo path is consistent across OS

        # Horizontal layout for logo and title
        logoLayout = QHBoxLayout()
        icon = QIcon(icon_path)  # Path to application icon
        pixmap = icon.pixmap(64)
        logo_label = QLabel()
        logo_label.setPixmap(pixmap)
        logoLayout.addWidget(logo_label)
        label = QLabel("Quad Cortex MIDI Controller")
        font = QFont()
        font.setPointSize(28)
        label.setFont(font)
        logoLayout.addWidget(label)
        mainLayout.addLayout(logoLayout)

        # Layouts for control buttons
        columnLayout = QHBoxLayout()

        # Left column: Gig View, Tuner, Looper, and Tap Tempo buttons
        leftLayout = QVBoxLayout()
        self.add_button(leftLayout, 'Toggle Gig View (K)', self.toggle_gig_view, 'K')
        self.add_button(leftLayout, 'Toggle Tuner (T)', self.toggle_tuner, 'T')
        self.add_button(leftLayout, 'Toggle Looper (L)', self.toggle_looper, 'L')
        self.add_button(leftLayout, 'Tap Tempo (1)', lambda: self.send_midi_cc(44, 127), '1')
        columnLayout.addLayout(leftLayout)

        # Middle column: Footswitches A-D
        middleLayout = QVBoxLayout()
        self.add_footswitches(middleLayout, [('FS A', 35, 'A'), ('FS B', 36, 'B'), ('FS C', 37, 'C'), ('FS D', 38, 'D')])
        columnLayout.addLayout(middleLayout)

        # Right column: Footswitches E-H
        rightLayout = QVBoxLayout()
        self.add_footswitches(rightLayout, [('FS E', 39, 'E'), ('FS F', 40, 'F'), ('FS G', 41, 'G'), ('FS H', 42, 'H')])
        columnLayout.addLayout(rightLayout)

        mainLayout.addLayout(columnLayout)

        # Output box and control buttons
        self.output_box = QTextEdit(self)
        self.output_box.setReadOnly(True)
        mainLayout.addWidget(self.output_box)

        self.add_button(mainLayout, 'Clear Output', self.clear_output_box)
        self.add_button(mainLayout, 'Quit (⌘ Q)', self.quit_application)

        # Footer with copyright and version
        footerLayout = QHBoxLayout()
        footerLayout.addWidget(QLabel('© 2024 hartsa.fi'))
        footerLayout.addStretch()
        footerLayout.addWidget(QLabel('Version 1.0'))
        mainLayout.addLayout(footerLayout)

        # Apply consistent style to the application
        self.setStyleSheet(self.get_stylesheet())
        self.setLayout(mainLayout)
        self.show()

    def find_midi_device(self):
        # Attempt to locate and connect to the MIDI device
        port_name = next((name for name in self.output_ports if self.device_name in name), None)
        if port_name is None:
            self.output_box.append(f"Error: {self.device_name} not found.")
            self.retry_connection_prompt()
        else:
            self.midi_port = mido.open_output(port_name)
            self.output_box.append(f"Connected to {self.device_name}.")

    def retry_connection_prompt(self):
        # Show a dialog to retry connection or quit
        msg_box = QMessageBox()
        msg_box.setWindowTitle("MIDI Device Error")
        msg_box.setText(f"{self.device_name} is not connected. Retry or Quit?")
        retry_button = msg_box.addButton("Retry", QMessageBox.AcceptRole)
        quit_button = msg_box.addButton("Quit", QMessageBox.RejectRole)
        response = msg_box.exec_()
        if response == QMessageBox.AcceptRole:
            self.output_ports = mido.get_output_names()
            self.find_midi_device()
        elif msg_box.clickedButton() == quit_button:
            self.quit_application()

    def log_midi_message(self, control, value):
        # Log MIDI messages with descriptions for debugging
        messages = {
            44: "TAP TEMPO PRESS",
            45: "TUNER",
            46: "GIG VIEW",
            48: "LOOPER"
        }
        if control in messages:
            state = "ON" if value >= 64 else "OFF"
            self.output_box.append(f"Sent {messages[control]} {state} (CC {control} value {value}) to {self.device_name}.")
        elif 35 <= control <= 42:  # Footswitches A-H
            label = chr(65 + control - 35)
            self.output_box.append(f"Sent FOOTSWITCH {label} (CC {control} value {value}) to {self.device_name}.")

    def send_midi_cc(self, control, value):
        # Send a MIDI control change message
        if self.midi_port is None:
            self.output_box.append(f"Error: {self.device_name} not connected.")
            return
        cc_message = mido.Message('control_change', control=control, value=value)
        self.midi_port.send(cc_message)
        self.log_midi_message(control, value)

    def run_command(self, command_func):
        # Execute a command if the MIDI port is connected
        if self.midi_port is None:
            QMessageBox.warning(self, "MIDI Device Error", f"{self.device_name} not connected.")
        else:
            command_func()

    def toggle_gig_view(self):
        # Toggle gig view state based on current state
        self.gig_view_state = self.toggle_state(self.gig_view_state, 'TOGGLE_GIG_VIEW')

    def toggle_tuner(self):
        # Toggle tuner state based on current state
        self.tuner_state = self.toggle_state(self.tuner_state, 'TOGGLE_TUNER')

    def toggle_looper(self):
        # Toggle looper state based on current state
        self.looper_state = self.toggle_state(self.looper_state, 'TOGGLE_LOOPER')

    def toggle_state(self, state, control_key):
        # Generic toggle function to update state and send MIDI CC
        new_state = self.MIDI_CC[control_key]['values']['ON'] if state == self.MIDI_CC[control_key]['values']['OFF'] else self.MIDI_CC[control_key]['values']['OFF']
        self.send_midi_cc(self.MIDI_CC[control_key]['control'], new_state)
        return new_state

    def add_button(self, layout, text, func, shortcut=None):
        # Helper function to add buttons to a layout
        button = QPushButton(text, self)
        button.clicked.connect(func)
        if shortcut:
            button.setShortcut(shortcut)
        layout.addWidget(button)

    def add_footswitches(self, layout, footswitches):
        # Helper function to add footswitch buttons to a layout
        for text, control, shortcut in footswitches:
            self.add_button(layout, f'{text} ({shortcut})', lambda ch, c=control: self.run_command(lambda: self.send_midi_cc(c, 127)), shortcut)

    def clear_output_box(self):
        # Clear the output box
        self.output_box.clear()

    def quit_application(self):
        # Cleanly exit the application
        if self.midi_port:
            self.midi_port.close()
        sys.exit()

    def get_stylesheet(self):
        # Return the application's stylesheet
        return """
            QWidget {
                color: #ffffff;
                background-color: #323232;
            }
            QPushButton {
                background-color: #424242;
                border: 1px solid #76797C;
                padding: 5px;
                border-radius: 5px;
                outline: none;
            }
            QPushButton:pressed {
                background-color: #4A4949;
            }
            QPushButton:hover {
                border: 2px solid #78879b;
            }
            QTextEdit {
                border: 1px solid #76797C;
                border-radius: 5px;
                padding: 5px;
            }
        """

if __name__ == '__main__':
    app = QApplication(sys.argv)
    ex = App()
    sys.exit(app.exec_())

Running and installing the app

Prepare the project directory

  1. Create a project directory:
    • Set up a directory for the project, for example, QC.
  2. Save the script:
    • Place the provided Python script (qc_midi_controller.py) in the project directory.
  3. Add the application Icon:
    • Save the application icon (icon.png) in the QC directory:
    • Path: ./QC/icon.png
    • Note: If you choose a different location for the icon, update the path in the script accordingly. Modify line 39 of the example code to: icon_path = os.path.join('<dir>', '<subdir>', 'icon.png')

Running the application

Navigate to the directory and run the script: python qc_midi_controller.py

Create a standalone macOS app (optional)

To make this application easier to launch:

  1. Install pyinstaller:
    • pip3 install pyinstaller
  2. Verify the path of the installed pyinstaller:
    • python3 -m site --user-base (e.g. /Users/<YourUsername>/Library/Python/3.x/bin/pyinstaller)
  3. Ensure that you are in the directory where the Python script and the icon file are located.
  4. Package the script into a macOS app:
    • <path_from_the_previous_command>/pyinstaller --onefile --windowed qc_midi_controller.py --hidden-import mido.backends.rtmidi --icon=icon.icns
  5. The app will be located in the dist folder.
  6. Test the app:
    • Double-click the .app file in the dist directory to run it.
    • Alternatively, execute it from the terminal: open dist/qc_midi_controller.app
  7. Install the app:
    • mv dist/qc_midi_controller.app /Applications/ (sudo if needed)

Enjoy!

To feel more like a proper compiled piece of software, this PoC was later C++-ified and packaged into a shareable macOS DMG installer. More on that another time (maybe).

Back