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).
Prerequisites (in this example for macOS)
- Install homebrew:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
- Install Python:
brew install python
- 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
- Create a project directory:
- Set up a directory for the project, for example, QC.
- Save the script:
- Place the provided Python script (qc_midi_controller.py) in the project directory.
- 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:
- Install pyinstaller:
pip3 install pyinstaller
- Verify the path of the installed pyinstaller:
python3 -m site --user-base
(e.g. /Users/<YourUsername>/Library/Python/3.x/bin/pyinstaller)
- Ensure that you are in the directory where the Python script and the icon file are located.
- 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
- The app will be located in the dist folder.
- 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
- 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).