From 3e2147fa02c5421659d89aea73ee46a469ff4065 Mon Sep 17 00:00:00 2001 From: Paul Lipscomb Date: Tue, 16 Jun 2026 22:37:38 -0400 Subject: [PATCH] initial commit --- .gitignore | 7 + colorswatchpopup.py | 31 + main.py | 2473 ++++++++++++++++++++++++++++++++++++++++++ midi_ports.py | 33 + midi_receiver.py | 11 + midi_sender.py | 25 + palette.py | 45 + presets.py | 17 + presets/presets.json | 105 ++ styles.py | 32 + zonebutton.py | 88 ++ 11 files changed, 2867 insertions(+) create mode 100644 .gitignore create mode 100644 colorswatchpopup.py create mode 100644 main.py create mode 100644 midi_ports.py create mode 100644 midi_receiver.py create mode 100644 midi_sender.py create mode 100644 palette.py create mode 100644 presets.py create mode 100644 presets/presets.json create mode 100644 styles.py create mode 100644 zonebutton.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1acd030 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +venv/ + +__pycache__/ + +*.pyc + +.DS_Store diff --git a/colorswatchpopup.py b/colorswatchpopup.py new file mode 100644 index 0000000..cfdafaa --- /dev/null +++ b/colorswatchpopup.py @@ -0,0 +1,31 @@ +from PyQt6.QtWidgets import QWidget, QHBoxLayout, QPushButton +from PyQt6.QtCore import Qt +from palette import PALETTE + +class ColorSwatchPopup(QWidget): + def __init__(self, callback, parent=None): + super().__init__(parent, Qt.WindowType.Popup) + self.callback = callback + layout = QHBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + layout.setSpacing(4) + for name, hex_color in PALETTE: + btn = QPushButton() + btn.setFixedSize(22, 22) + btn.setStyleSheet(f""" + QPushButton {{ + background-color: {hex_color}; + border-radius: 3px; + border: 1px solid rgba(255,255,255,0.2); + }} + QPushButton:hover {{ + border: 2px solid #00ff88; + }} + """) + btn.clicked.connect(lambda checked, n=name, c=hex_color: self.pick(n, c)) + layout.addWidget(btn) + self.setStyleSheet("background-color: #2d2d2d; border-radius: 4px;") + + def pick(self, name, hex_color): + self.callback(name, hex_color) + self.close() \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..47d4c7b --- /dev/null +++ b/main.py @@ -0,0 +1,2473 @@ +import sys +import json +import os +import uuid +import rtmidi +from PyQt6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, + QHBoxLayout, QSlider, QComboBox, QLabel, QPushButton, + QSizePolicy, QSpinBox, QLineEdit, QInputDialog, QFrame, + QCheckBox +) +from PyQt6.QtCore import Qt, QObject, pyqtSignal, QTimer, QEvent +from PyQt6.QtGui import QAction, QColor +from pythonosc import dispatcher as osc_dispatcher +from pythonosc import osc_server +import threading + +from palette import ( + PALETTE, + PALETTE_NAMES, + PALETTE_HEX, + PALETTE_VIVID, + lighten_hex, + darken_hex, +) + +from styles import ( + FADER_STYLE, + TITLE_STYLE, +) + +from presets import ( + load_presets_file, + save_presets_file, +) + +from zonebutton import ZoneButton + +from colorswatchpopup import ColorSwatchPopup + +from midi_receiver import midi_receiver + +from midi_sender import ( + midi_out, + transport_midi_out, + available_ports, + send_cc, + send_transport_cc, +) +from midi_ports import ( + get_available_input_ports, + open_midi_input_port, + close_midi_input_port, +) + +def send_osc_message(address, value=1.0): + """Send an OSC message to REAPER.""" + from pythonosc import udp_client + try: + client = udp_client.SimpleUDPClient("127.0.0.1", 8000) # REAPER OSC send port + client.send_message(address, float(value)) + midi_receiver.transport_out_signal.emit() + except Exception as e: + print(f"[OSC send] Error: {e}") + +_osc_client = None + +_osc_send_port = 8000 +_osc_send_ip = "127.0.0.1" + +def get_osc_client(): + global _osc_client + if _osc_client is None: + from pythonosc import udp_client + _osc_client = udp_client.SimpleUDPClient(_osc_send_ip, _osc_send_port) + return _osc_client + +def send_osc_message(address, value=1.0): + try: + get_osc_client().send_message(address, float(value)) + midi_receiver.transport_out_signal.emit() + osc_receiver.osc_out_signal.emit(address, str(value)) + print(f"[OSC] → {address} {value}") + except Exception as e: + print(f"[OSC send] Error: {e}") + + +def set_window(w): + global _window + _window = w + +# --- OSC Receiver --- +class OSCReceiver(QObject): + track_name_signal = pyqtSignal(str) + preset_name_signal = pyqtSignal(str) + tempo_signal = pyqtSignal(float) + position_signal = pyqtSignal(str) + play_signal = pyqtSignal(int) + record_signal = pyqtSignal(int) + raw_message_signal = pyqtSignal(str, str) # address, value string + osc_out_signal = pyqtSignal(str, str) # address, value string + +osc_receiver = OSCReceiver() + +_osc_server = None +_osc_thread = None + +def start_osc_server(port=9000): + global _osc_server, _osc_thread + stop_osc_server() + + d = osc_dispatcher.Dispatcher() + + def emit_raw(addr, *args): + val_str = " ".join(str(a) for a in args) if args else "" + osc_receiver.raw_message_signal.emit(addr, val_str) + + def on_play(addr, *args): + emit_raw(addr, *args) + if args: osc_receiver.play_signal.emit(int(args[0])) + def on_record(addr, *args): + emit_raw(addr, *args) + if args: osc_receiver.record_signal.emit(int(args[0])) + def on_tempo(addr, *args): + emit_raw(addr, *args) + if args: osc_receiver.tempo_signal.emit(float(args[0])) + def on_beat(addr, *args): + emit_raw(addr, *args) + if args: osc_receiver.position_signal.emit(str(args[0])) + def on_track_name(addr, *args): + emit_raw(addr, *args) + if args: osc_receiver.track_name_signal.emit(str(args[0])) + def on_preset(addr, *args): + emit_raw(addr, *args) + if args: osc_receiver.preset_name_signal.emit(str(args[0])) + def on_default(addr, *args): + emit_raw(addr, *args) + + d.map("/play", on_play) + d.map("/record", on_record) + d.map("/tempo", on_tempo) + d.map("/beat/str", on_beat) + d.map("/track/*", on_track_name) + d.map("/fx/*", on_preset) + d.map("/fxname", on_preset) + d.set_default_handler(on_default) + + try: + _osc_server = osc_server.ThreadingOSCUDPServer(("0.0.0.0", port), d) + _osc_thread = threading.Thread(target=_osc_server.serve_forever, daemon=True) + _osc_thread.start() + print(f"[OSC] Listening on port {port}") + return True + except Exception as e: + print(f"[OSC] Failed to start: {e}") + _osc_server = None + return False + +def stop_osc_server(): + global _osc_server, _osc_thread + if _osc_server: + _osc_server.shutdown() + _osc_server = None + _osc_thread = None + + + + +class FaderWidget(QWidget): + def __init__(self, label, default_cc): + super().__init__() + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) + self.setFixedWidth(100) + self.current_color = "transparent" + + self.uid = str(uuid.uuid4()) + + # Pickup mode state + self.pickup_mode = False + self.last_sent = 64 + self.prev_position = 64 + self.hunting = False + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(2) + + self.container = QFrame() + self.container.setFrameShape(QFrame.Shape.StyledPanel) + self.apply_color(self.current_color) + + inner = QVBoxLayout(self.container) + inner.setContentsMargins(4, 6, 4, 6) + inner.setSpacing(4) + inner.setAlignment(Qt.AlignmentFlag.AlignHCenter) + + # Learn button + self.hw_cc = None + self.is_learning = False + self.learn_btn = QPushButton("Learn") + self.learn_btn.setFixedHeight(20) + self.learn_btn.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.learn_btn.customContextMenuRequested.connect(self.on_learn_context_menu) + inner.addWidget(self.learn_btn) + + # CC In label + self.cc_input_lbl = QLabel("") + self.cc_input_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px;") + inner.addWidget(self.cc_input_lbl) + + # CC spin + self.cc_spin = QSpinBox() + self.cc_spin.setMinimum(1) + self.cc_spin.setMaximum(127) + self.cc_spin.setValue(default_cc) + self.cc_spin.setFixedWidth(75) + inner.addWidget(self.cc_spin, alignment=Qt.AlignmentFlag.AlignHCenter) + + self.cc_output_lbl = QLabel("") + self.cc_output_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.cc_output_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;") + inner.addWidget(self.cc_output_lbl) + + # Fader + self.fader = QSlider(Qt.Orientation.Vertical) + self.fader.setMinimum(0) + self.fader.setMaximum(127) + self.fader.setValue(64) + self.fader.setFixedHeight(200) + self.fader.setFixedWidth(40) + self.fader.setStyleSheet(FADER_STYLE) + inner.addWidget(self.fader, alignment=Qt.AlignmentFlag.AlignHCenter) + + # Readout + # readout kept for pickup blink logic but not shown + self.readout = QLabel(f"CC {default_cc} → {self.fader.value()}") + self.readout.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.readout.setVisible(False) + + # Pickup checkbox + self.pickup_checkbox = QCheckBox("Pickup") + self.pickup_checkbox.setChecked(False) + self.pickup_checkbox.stateChanged.connect(self.on_pickup_changed) + inner.addWidget(self.pickup_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter) + + # Scribble strip + self.title_edit = QLineEdit(label) + self.title_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.title_edit.setStyleSheet(TITLE_STYLE) + inner.addWidget(self.title_edit) + + # Color swatch button + self.color_name = "Gray" + self.color_swatch_btn = QPushButton() + self.color_swatch_btn.setFixedHeight(10) + self.color_swatch_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID['Gray']}; border-radius: 2px; border: none;") + self.color_swatch_btn.clicked.connect(self.show_color_popup) + inner.addWidget(self.color_swatch_btn) + + # + / - buttons + btn_row = QHBoxLayout() + btn_row.setContentsMargins(0, 0, 0, 0) + btn_row.setSpacing(2) + self.minus_btn = QPushButton("-") + self.minus_btn.setFixedSize(30, 20) + self.plus_btn = QPushButton("+") + self.plus_btn.setFixedSize(30, 20) + btn_row.addWidget(self.minus_btn) + btn_row.addStretch() + btn_row.addWidget(self.plus_btn) + inner.addLayout(btn_row) + + # Zone button + self.zone_btn = ZoneButton() + self.zone_btn.left_callback = lambda active: self.on_zone_checked("left", active) + self.zone_btn.right_callback = lambda active: self.on_zone_checked("right", active) + inner.addWidget(self.zone_btn) + + # Separator before ID label + id_sep = QWidget() + id_sep.setFixedHeight(1) + id_sep.setStyleSheet("background-color: #3a3a3a;") + inner.addWidget(id_sep) + + # Channel ID label + self.id_label = QLabel("") + self.id_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.id_label.setStyleSheet("color: #ffffff; font-size: 13px; font-weight: bold; background: transparent;") + inner.addWidget(self.id_label) + + outer.addWidget(self.container) + + self.fader.valueChanged.connect(self.on_fader_moved) + self.cc_spin.valueChanged.connect(self.on_cc_changed) + self.on_select_callback = None + self.hw_cc = None # bound hardware CC number + self.is_learning = False + self.zone = None # None, "left", or "right" + self.center_index = None # saved position in center row before zoning + self.zone_index = None # position within zone slot + self.on_zone_change_callback = None + + # Install event filter on all children to forward clicks to select + for child in self.findChildren(QWidget): + child.installEventFilter(self) + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.MouseButtonPress: + if self.on_select_callback: + self.on_select_callback(self) + return False + + def mousePressEvent(self, event): + if self.on_select_callback: + self.on_select_callback(self) + super().mousePressEvent(event) + + def on_pickup_changed(self, state): + self.pickup_mode = bool(state) + if self.pickup_mode: + self.hunting = True + self.prev_position = self.fader.value() + self.pickup_checkbox.setStyleSheet("color: orange; font-weight: bold;") + self._start_blink() + else: + self.hunting = False + self.pickup_checkbox.setStyleSheet("color: black;") + self._stop_blink() + self._set_readout_style("normal") + + def _set_readout_style(self, mode): + if mode == "normal": + self.cc_output_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;") + self.pickup_checkbox.setStyleSheet("color: black;") if not self.pickup_mode else None + elif mode == "orange": + self.cc_output_lbl.setStyleSheet("background-color: #000000; color: orange; font-size: 10px; font-weight: bold; padding: 1px;") + elif mode == "clear": + self.cc_output_lbl.setStyleSheet("background-color: #000000; color: transparent; font-size: 10px; padding: 1px;") + + def _start_blink(self): + if not hasattr(self, '_blink_timer'): + self._blink_timer = QTimer() + self._blink_timer.timeout.connect(self._do_blink) + self._blink_state = False + self._blink_timer.start(400) + + def _stop_blink(self): + if hasattr(self, '_blink_timer'): + self._blink_timer.stop() + self._set_readout_style("normal") + + def _do_blink(self): + self._blink_state = not self._blink_state + if self._blink_state: + self._set_readout_style("orange") + else: + self._set_readout_style("clear") + + def on_fader_moved(self, value): + cc_num = self.cc_spin.value() + self.cc_output_lbl.setText(f"Out CC{cc_num} [{value}]") + if self.pickup_mode and self.hunting: + crossed = ( + (self.prev_position < self.last_sent <= value) or + (self.prev_position > self.last_sent >= value) + ) + self.prev_position = value + if crossed: + self.hunting = False + self._stop_blink() + self._set_readout_style("orange") + self.last_sent = value + send_cc(cc_num, value) + else: + self.readout.setText(f"CC {cc_num} → {value}") + return + else: + self.last_sent = value + self.prev_position = value + send_cc(cc_num, value) + self.readout.setText(f"CC {cc_num} → {value}") + + def on_cc_changed(self): + cc_num = self.cc_spin.value() + value = self.fader.value() + self.readout.setText(f"CC {cc_num} → {value}") + self.cc_output_lbl.setText(f"Out CC{cc_num} [{value}]") + if self.pickup_mode: + self.hunting = True + self._start_blink() + + def apply_color(self, hex_color, selected=False): + self.current_color = hex_color + border = "#00ff88" if selected else "rgba(0,0,0,0.3)" + border_width = "3px" if selected else "2px" + self.container.setObjectName("stripContainer") + self.container.setStyleSheet(f""" + QFrame#stripContainer {{ + background-color: {hex_color}; + border-radius: 6px; + border: {border_width} solid {border}; + }} + """) + + def set_selected(self, selected): + self.apply_color(self.current_color, selected=selected) + + def update_id_label(self, zone, index): + if zone == "left": + self.id_label.setText(f"L{index + 1}") + elif zone == "right": + self.id_label.setText(f"R{index + 1}") + else: + self.id_label.setText(f"{index + 1}") + + def on_zone_checked(self, zone, active): + if active: + self.zone = zone + else: + self.zone = None + if self.on_zone_change_callback: + self.on_zone_change_callback(self) + + def show_color_popup(self): + popup = ColorSwatchPopup(self.on_color_change, self) + btn_pos = self.color_swatch_btn.mapToGlobal(self.color_swatch_btn.rect().bottomLeft()) + popup.move(btn_pos) + popup.show() + + def on_color_change(self, name, hex_color=None): + if hex_color is None: + hex_color = PALETTE_HEX.get(name, self.current_color) + self.color_name = name + self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID.get(self.color_name, hex_color)}; border-radius: 2px; border: none;") + self.apply_color(hex_color) + + def on_zone_checked(self, zone, active): + if active: + self.zone = zone + else: + self.zone = None + if self.on_zone_change_callback: + self.on_zone_change_callback(self) + + def on_learn_context_menu(self, pos): + from PyQt6.QtWidgets import QMenu + if self.hw_cc is None: + return + menu = QMenu(self) + clear_action = menu.addAction("Clear Input Assignment") + action = menu.exec(self.learn_btn.mapToGlobal(pos)) + if action == clear_action: + self.hw_cc = None + self.is_learning = False + self.learn_btn.setText("Learn") + self.learn_btn.setStyleSheet("") + self.cc_input_lbl.setText("") + self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px;") + + def update_id_label(self, zone, index): + if zone == "left": + self.id_label.setText(f"L{index + 1}") + elif zone == "right": + self.id_label.setText(f"R{index + 1}") + else: + self.id_label.setText(f"{index + 1}") + + def start_learn(self): + self.is_learning = True + self.learn_btn.setText("Listening...") + self.learn_btn.setStyleSheet("color: orange; font-weight: bold;") + + def stop_learn(self): + self.is_learning = False + self.learn_btn.setStyleSheet("") + if self.hw_cc is not None: + self.learn_btn.setText(f"HW: CC{self.hw_cc}") + self.cc_input_lbl.setText(f"In CC{self.hw_cc} [--]") + self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;") + else: + self.learn_btn.setText("Learn") + self.cc_input_lbl.setText("") + self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px;") + + def bind_hw_cc(self, cc_number): + self.hw_cc = cc_number + self.stop_learn() + + def get_state(self): + return { + "uid": self.uid, + "label": self.title_edit.text(), + "cc": self.cc_spin.value(), + "value": self.fader.value(), + "color": self.color_name, + "pickup": self.pickup_checkbox.isChecked(), + "last_sent": self.last_sent, + "hw_cc": self.hw_cc, + "zone": self.zone, + "center_index": self.center_index, + "zone_index": self.zone_index + } + + def set_state(self, state): + if "uid" in state: + self.uid = state["uid"] + self.title_edit.setText(state.get("label", "")) + self.cc_spin.setValue(state.get("cc", 1)) + self.last_sent = state.get("last_sent", 64) + self.fader.setValue(state.get("value", 64)) + color_name = state.get("color", "Gray") + hex_color = PALETTE_HEX.get(color_name, "transparent") + self.color_name = color_name + self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID.get(self.color_name, hex_color)}; border-radius: 2px; border: none;") + self.apply_color(hex_color) + pickup = state.get("pickup", False) + self.pickup_checkbox.setChecked(pickup) + if pickup: + self.hunting = True + self._start_blink() + self.zone = state.get("zone", None) + self.center_index = state.get("center_index", None) + self.zone_index = state.get("zone_index", None) + self.zone_btn.set_state(self.zone == "left", self.zone == "right") + self.hw_cc = state.get("hw_cc", None) + if self.hw_cc is not None: + self.learn_btn.setText(f"HW: CC{self.hw_cc}") + self.cc_input_lbl.setText(f"In CC{self.hw_cc} [--]") + self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;") + else: + self.learn_btn.setText("Learn") + self.cc_input_lbl.setText("") + self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px;") + + +class ToggleWidget(QWidget): + def __init__(self, label, default_cc): + super().__init__() + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) + self.setFixedWidth(100) + self.current_color = "transparent" + self.toggle_state = False + self.uid = str(uuid.uuid4()) + self.zone = None + self.center_index = None + self.zone_index = None + self.on_zone_change_callback = None + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(2) + + self.container = QFrame() + self.container.setFrameShape(QFrame.Shape.StyledPanel) + self.apply_color(self.current_color) + + inner = QVBoxLayout(self.container) + inner.setContentsMargins(4, 6, 4, 6) + inner.setSpacing(4) + inner.setAlignment(Qt.AlignmentFlag.AlignHCenter) + + # Learn button + self.hw_cc = None + self.is_learning = False + self.learn_btn = QPushButton("Learn") + self.learn_btn.setFixedHeight(20) + self.learn_btn.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.learn_btn.customContextMenuRequested.connect(self.on_learn_context_menu) + inner.addWidget(self.learn_btn) + + # CC In label + self.cc_input_lbl = QLabel("") + self.cc_input_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px;") + inner.addWidget(self.cc_input_lbl) + + # CC spin + self.cc_spin = QSpinBox() + self.cc_spin.setMinimum(1) + self.cc_spin.setMaximum(127) + self.cc_spin.setValue(default_cc) + self.cc_spin.setFixedWidth(75) + inner.addWidget(self.cc_spin, alignment=Qt.AlignmentFlag.AlignHCenter) + + self.cc_output_lbl = QLabel(f"Out CC{default_cc} [--]") + self.cc_output_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.cc_output_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;") + inner.addWidget(self.cc_output_lbl) + + # Toggle button with LED dot overlay + btn_container = QWidget() + btn_container.setFixedSize(60, 60) + + self.btn = QPushButton("", btn_container) + self.btn.setFixedSize(60, 60) + self.btn.setStyleSheet("") + self.btn.clicked.connect(self.on_click) + + self.led_dot = QLabel(btn_container) + self.led_dot.setFixedSize(6, 6) + self.led_dot.move(5, 49) + self.led_dot.setStyleSheet("background-color: #3c3c3c; border-radius: 3px;") + + inner.addWidget(btn_container, alignment=Qt.AlignmentFlag.AlignHCenter) + + # Scribble strip + self.title_edit = QLineEdit(label) + self.title_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.title_edit.setStyleSheet(TITLE_STYLE) + inner.addWidget(self.title_edit) + + # Color swatch button + self.color_name = "Gray" + self.color_swatch_btn = QPushButton() + self.color_swatch_btn.setFixedHeight(10) + self.color_swatch_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID['Gray']}; border-radius: 2px; border: none;") + self.color_swatch_btn.clicked.connect(self.show_color_popup) + inner.addWidget(self.color_swatch_btn) + + # + / - buttons inside container below color swatch + toggle_btn_row = QHBoxLayout() + toggle_btn_row.setContentsMargins(0, 0, 0, 0) + toggle_btn_row.setSpacing(2) + self.minus_btn = QPushButton("-") + self.minus_btn.setFixedSize(30, 20) + self.plus_btn = QPushButton("+") + self.plus_btn.setFixedSize(30, 20) + toggle_btn_row.addWidget(self.minus_btn) + toggle_btn_row.addStretch() + toggle_btn_row.addWidget(self.plus_btn) + inner.addLayout(toggle_btn_row) + + # Zone button + self.zone_btn = ZoneButton() + self.zone_btn.left_callback = lambda active: self.on_zone_checked("left", active) + self.zone_btn.right_callback = lambda active: self.on_zone_checked("right", active) + inner.addWidget(self.zone_btn) + + # Separator before ID label + id_sep = QWidget() + id_sep.setFixedHeight(1) + id_sep.setStyleSheet("background-color: #3a3a3a;") + inner.addWidget(id_sep) + + # Channel ID label + self.id_label = QLabel("") + self.id_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.id_label.setStyleSheet("color: #ffffff; font-size: 13px; font-weight: bold; background: transparent;") + inner.addWidget(self.id_label) + + outer.addWidget(self.container) + self.on_select_callback = None + + for child in self.findChildren(QWidget): + child.installEventFilter(self) + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.MouseButtonPress: + if self.on_select_callback: + self.on_select_callback(self) + return False + + def mousePressEvent(self, event): + if self.on_select_callback: + self.on_select_callback(self) + super().mousePressEvent(event) + + def apply_color(self, hex_color, selected=False): + self.current_color = hex_color + border = "#00ff88" if selected else "rgba(0,0,0,0.3)" + border_width = "3px" if selected else "2px" + self.container.setObjectName("stripContainer") + self.container.setStyleSheet(f""" + QFrame#stripContainer {{ + background-color: {hex_color}; + border-radius: 6px; + border: {border_width} solid {border}; + }} + """) + + def set_selected(self, selected): + self.apply_color(self.current_color, selected=selected) + + def start_learn(self): + self.is_learning = True + self.learn_btn.setText("Listening...") + self.learn_btn.setStyleSheet("color: orange; font-weight: bold;") + + def stop_learn(self): + self.is_learning = False + self.learn_btn.setStyleSheet("") + if self.hw_cc is not None: + self.learn_btn.setText(f"HW: CC{self.hw_cc}") + self.cc_input_lbl.setText(f"In CC{self.hw_cc} [--]") + self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;") + else: + self.learn_btn.setText("Learn") + self.cc_input_lbl.setText("") + self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px;") + + def bind_hw_cc(self, cc_number): + self.hw_cc = cc_number + self.stop_learn() + + def on_learn_context_menu(self, pos): + from PyQt6.QtWidgets import QMenu + if self.hw_cc is None: + return + menu = QMenu(self) + clear_action = menu.addAction("Clear Input Assignment") + action = menu.exec(self.learn_btn.mapToGlobal(pos)) + if action == clear_action: + self.hw_cc = None + self.is_learning = False + self.learn_btn.setText("Learn") + self.learn_btn.setStyleSheet("") + self.cc_input_lbl.setText("") + + def update_id_label(self, zone, index): + if zone == "left": + self.id_label.setText(f"L{index + 1}") + elif zone == "right": + self.id_label.setText(f"R{index + 1}") + else: + self.id_label.setText(f"{index + 1}") + + def on_zone_checked(self, zone, active): + if active: + self.zone = zone + else: + self.zone = None + if self.on_zone_change_callback: + self.on_zone_change_callback(self) + + def show_color_popup(self): + popup = ColorSwatchPopup(self.on_color_change, self) + btn_pos = self.color_swatch_btn.mapToGlobal(self.color_swatch_btn.rect().bottomLeft()) + popup.move(btn_pos) + popup.show() + + def on_color_change(self, name, hex_color=None): + if hex_color is None: + hex_color = PALETTE_HEX.get(name, self.current_color) + self.color_name = name + self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID.get(self.color_name, hex_color)}; border-radius: 2px; border: none;") + self.apply_color(hex_color) + self._update_btn_style() + + def on_click(self): + self.toggle_state = not self.toggle_state + cc_num = self.cc_spin.value() + value = 127 if self.toggle_state else 0 + send_cc(cc_num, value) + self.cc_output_lbl.setText(f"Out CC{cc_num} [{value}]") + self._update_btn_style() + + def _update_btn_style(self): + if self.toggle_state: + self.led_dot.setStyleSheet("background-color: #00e676; border-radius: 3px;") + else: + self.led_dot.setStyleSheet("background-color: #3c3c3c; border-radius: 3px;") + + def get_state(self): + return { + "uid": self.uid, + "label": self.title_edit.text(), + "cc": self.cc_spin.value(), + "state": self.toggle_state, + "color": self.color_name, + "zone": self.zone, + "center_index": self.center_index, + "zone_index": self.zone_index, + "hw_cc": self.hw_cc + } + + def set_state(self, state): + if "uid" in state: + self.uid = state["uid"] + self.title_edit.setText(state.get("label", "")) + self.cc_spin.setValue(state.get("cc", 1)) + color_name = state.get("color", "Gray") + hex_color = PALETTE_HEX.get(color_name, "transparent") + self.color_name = color_name + self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID.get(self.color_name, hex_color)}; border-radius: 2px; border: none;") + self.apply_color(hex_color) + self.toggle_state = state.get("state", False) + self._update_btn_style() + cc_num = self.cc_spin.value() + value = 127 if self.toggle_state else 0 + self.cc_output_lbl.setText(f"Out CC{cc_num} [{value}]") + self.zone = state.get("zone", None) + self.center_index = state.get("center_index", None) + self.zone_index = state.get("zone_index", None) + self.zone_btn.set_state(self.zone == "left", self.zone == "right") + self.hw_cc = state.get("hw_cc", None) + if self.hw_cc is not None: + self.learn_btn.setText(f"HW: CC{self.hw_cc}") + self.cc_input_lbl.setText(f"In CC{self.hw_cc} [--]") + self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;") + else: + self.learn_btn.setText("Learn") + self.cc_input_lbl.setText("") + + + + +class TransportWidget(QWidget): + """Transport button strip — same pattern as ToggleWidget.""" + def __init__(self, label, default_cc): + super().__init__() + self.uid = str(uuid.uuid4()) + self.zone = None + self.center_index = None + self.zone_index = None + self.toggle_state = False + self.hw_cc = None + self.is_learning = False + self.current_color = "transparent" + self.color_name = "Gray" + self.on_select_callback = None + self.on_zone_change_callback = None + self._label = label + + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred) + self.setFixedWidth(100) + + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(2) + + self.container = QFrame() + self.container.setFrameShape(QFrame.Shape.StyledPanel) + self.apply_color(self.current_color) + + inner = QVBoxLayout(self.container) + inner.setContentsMargins(4, 6, 4, 6) + inner.setSpacing(4) + inner.setAlignment(Qt.AlignmentFlag.AlignHCenter) + + # Learn button + self.learn_btn = QPushButton("Learn") + self.learn_btn.setFixedHeight(20) + self.learn_btn.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.learn_btn.customContextMenuRequested.connect(self.on_learn_context_menu) + inner.addWidget(self.learn_btn) + + # CC In label + self.cc_input_lbl = QLabel("") + self.cc_input_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px;") + inner.addWidget(self.cc_input_lbl) + + # CC spin + self.cc_spin = QSpinBox() + self.cc_spin.setMinimum(1) + self.cc_spin.setMaximum(127) + self.cc_spin.setValue(default_cc) + self.cc_spin.setFixedWidth(75) + inner.addWidget(self.cc_spin, alignment=Qt.AlignmentFlag.AlignHCenter) + + # CC Out label + self.cc_output_lbl = QLabel(f"Out CC{default_cc} [--]") + self.cc_output_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.cc_output_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;") + inner.addWidget(self.cc_output_lbl) + + # MIDI / OSC mode selector + mode_row = QHBoxLayout() + mode_row.setContentsMargins(0, 0, 0, 0) + mode_row.setSpacing(2) + self.midi_btn = QPushButton("MIDI") + self.midi_btn.setFixedHeight(18) + self.midi_btn.setCheckable(True) + self.midi_btn.setChecked(True) + self.osc_btn = QPushButton("OSC") + self.osc_btn.setFixedHeight(18) + self.osc_btn.setCheckable(True) + self.osc_btn.setChecked(False) + self.midi_btn.clicked.connect(lambda: self.set_output_mode("midi")) + self.osc_btn.clicked.connect(lambda: self.set_output_mode("osc")) + mode_row.addWidget(self.midi_btn) + mode_row.addWidget(self.osc_btn) + inner.addLayout(mode_row) + + # Momentary / Toggle selector + trigger_row = QHBoxLayout() + trigger_row.setContentsMargins(0, 0, 0, 0) + trigger_row.setSpacing(2) + self.momentary_btn = QPushButton("Mom.") + self.momentary_btn.setFixedHeight(18) + self.momentary_btn.setCheckable(True) + self.momentary_btn.setChecked(True) + self.toggle_mode_btn = QPushButton("Tog.") + self.toggle_mode_btn.setFixedHeight(18) + self.toggle_mode_btn.setCheckable(True) + self.toggle_mode_btn.setChecked(False) + self.momentary_btn.clicked.connect(lambda: self.set_trigger_mode("momentary")) + self.toggle_mode_btn.clicked.connect(lambda: self.set_trigger_mode("toggle")) + trigger_row.addWidget(self.momentary_btn) + trigger_row.addWidget(self.toggle_mode_btn) + inner.addLayout(trigger_row) + + self.trigger_mode = "momentary" # "momentary" or "toggle" + + # OSC address field (hidden by default) + self.osc_addr_edit = QLineEdit("/play") + self.osc_addr_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.osc_addr_edit.setFixedWidth(75) + self.osc_addr_edit.setPlaceholderText("/address") + self.osc_addr_edit.setVisible(False) + inner.addWidget(self.osc_addr_edit, alignment=Qt.AlignmentFlag.AlignHCenter) + + self.output_mode = "midi" # "midi" or "osc" + + # Transport button with LED dot + btn_container = QWidget() + btn_container.setFixedSize(60, 60) + + self.btn = QPushButton(label, btn_container) + self.btn.setFixedSize(60, 60) + self.btn.clicked.connect(self.on_click) + + self.led_dot = QLabel(btn_container) + self.led_dot.setFixedSize(6, 6) + self.led_dot.move(5, 49) + self.led_dot.setStyleSheet("background-color: #3c3c3c; border-radius: 3px;") + + inner.addWidget(btn_container, alignment=Qt.AlignmentFlag.AlignHCenter) + + # Scribble strip + self.title_edit = QLineEdit(label) + self.title_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.title_edit.setStyleSheet(TITLE_STYLE) + inner.addWidget(self.title_edit) + + # Color swatch + self.color_swatch_btn = QPushButton() + self.color_swatch_btn.setFixedHeight(10) + self.color_swatch_btn.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID['Gray']}; border-radius: 2px; border: none;") + self.color_swatch_btn.clicked.connect(self.show_color_popup) + inner.addWidget(self.color_swatch_btn) + + # + / - buttons + btn_row = QHBoxLayout() + btn_row.setContentsMargins(0, 0, 0, 0) + btn_row.setSpacing(2) + self.minus_btn = QPushButton("-") + self.minus_btn.setFixedSize(30, 20) + self.plus_btn = QPushButton("+") + self.plus_btn.setFixedSize(30, 20) + btn_row.addWidget(self.minus_btn) + btn_row.addStretch() + btn_row.addWidget(self.plus_btn) + inner.addLayout(btn_row) + + # Zone button + self.zone_btn = ZoneButton() + self.zone_btn.left_callback = lambda active: self.on_zone_checked("left", active) + self.zone_btn.right_callback = lambda active: self.on_zone_checked("right", active) + inner.addWidget(self.zone_btn) + + # Separator + ID label + id_sep = QWidget() + id_sep.setFixedHeight(1) + id_sep.setStyleSheet("background-color: #3a3a3a;") + inner.addWidget(id_sep) + + self.id_label = QLabel("") + self.id_label.setAlignment(Qt.AlignmentFlag.AlignHCenter) + self.id_label.setStyleSheet("color: #ffffff; font-size: 13px; font-weight: bold; background: transparent;") + inner.addWidget(self.id_label) + + outer.addWidget(self.container) + + for child in self.findChildren(QWidget): + child.installEventFilter(self) + + def eventFilter(self, obj, event): + if event.type() == QEvent.Type.MouseButtonPress: + if self.on_select_callback: + self.on_select_callback(self) + return False + + def mousePressEvent(self, event): + if self.on_select_callback: + self.on_select_callback(self) + super().mousePressEvent(event) + + def set_trigger_mode(self, mode): + self.trigger_mode = mode + if mode == "momentary": + self.momentary_btn.setChecked(True) + self.toggle_mode_btn.setChecked(False) + self.momentary_btn.setStyleSheet("font-weight: bold; color: #00e676;") + self.toggle_mode_btn.setStyleSheet("") + else: + self.toggle_mode_btn.setChecked(True) + self.momentary_btn.setChecked(False) + self.toggle_mode_btn.setStyleSheet("font-weight: bold; color: #00e676;") + self.momentary_btn.setStyleSheet("") + + def set_output_mode(self, mode): + self.output_mode = mode + if mode == "midi": + self.midi_btn.setChecked(True) + self.osc_btn.setChecked(False) + self.midi_btn.setStyleSheet("font-weight: bold; color: #00e676;") + self.osc_btn.setStyleSheet("") + self.cc_spin.setVisible(True) + self.cc_output_lbl.setVisible(True) + self.osc_addr_edit.setVisible(False) + else: + self.osc_btn.setChecked(True) + self.midi_btn.setChecked(False) + self.osc_btn.setStyleSheet("font-weight: bold; color: #00e676;") + self.midi_btn.setStyleSheet("") + self.cc_spin.setVisible(False) + self.cc_output_lbl.setVisible(False) + self.osc_addr_edit.setVisible(True) + + def on_click(self): + if self.trigger_mode == "momentary": + if self.output_mode == "osc": + addr = self.osc_addr_edit.text().strip() or "/play" + send_osc_message(addr, 1.0) + self.cc_output_lbl.setText(f"OSC {addr}") + else: + cc_num = self.cc_spin.value() + send_transport_cc(cc_num, 127) + self.cc_output_lbl.setText(f"Out CC{cc_num} [127]") + self.led_dot.setStyleSheet("background-color: #00e676; border-radius: 3px;") + QTimer.singleShot(150, lambda: self.led_dot.setStyleSheet("background-color: #3c3c3c; border-radius: 3px;")) + else: + self.toggle_state = not self.toggle_state + value = 127 if self.toggle_state else 0 + if self.output_mode == "osc": + addr = self.osc_addr_edit.text().strip() or "/play" + send_osc_message(addr, 1.0 if self.toggle_state else 0.0) + self.cc_output_lbl.setText(f"OSC {addr}") + else: + cc_num = self.cc_spin.value() + send_transport_cc(cc_num, value) + self.cc_output_lbl.setText(f"Out CC{cc_num} [{value}]") + self._update_btn_style() + + def _update_btn_style(self): + if self.toggle_state: + self.led_dot.setStyleSheet("background-color: #00e676; border-radius: 3px;") + else: + self.led_dot.setStyleSheet("background-color: #3c3c3c; border-radius: 3px;") + + def apply_color(self, hex_color, selected=False): + self.current_color = hex_color + border = "#00ff88" if selected else "rgba(0,0,0,0.3)" + border_width = "3px" if selected else "2px" + self.container.setObjectName("stripContainer") + self.container.setStyleSheet(f""" + QFrame#stripContainer {{ + background-color: {hex_color}; + border-radius: 6px; + border: {border_width} solid {border}; + }} + """) + + def set_selected(self, selected): + self.apply_color(self.current_color, selected=selected) + + def update_id_label(self, zone, index): + text = f"L{index+1}" if zone == "left" else f"R{index+1}" if zone == "right" else f"{index+1}" + self.id_label.setText(text) + + def show_color_popup(self): + popup = ColorSwatchPopup(self.on_color_change, self) + btn_pos = self.color_swatch_btn.mapToGlobal(self.color_swatch_btn.rect().bottomLeft()) + popup.move(btn_pos) + popup.show() + + def on_color_change(self, name, hex_color=None): + if hex_color is None: + hex_color = PALETTE_HEX.get(name, self.current_color) + self.color_name = name + self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID.get(name, hex_color)}; border-radius: 2px; border: none;") + self.apply_color(hex_color) + + def on_zone_checked(self, zone, active): + self.zone = zone if active else None + if self.on_zone_change_callback: + self.on_zone_change_callback(self) + + def start_learn(self): + self.is_learning = True + self.learn_btn.setText("Listening...") + self.learn_btn.setStyleSheet("color: orange; font-weight: bold;") + + def stop_learn(self): + self.is_learning = False + self.learn_btn.setStyleSheet("") + if self.hw_cc is not None: + self.learn_btn.setText(f"HW: CC{self.hw_cc}") + self.cc_input_lbl.setText(f"In CC{self.hw_cc} [--]") + self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;") + else: + self.learn_btn.setText("Learn") + self.cc_input_lbl.setText("") + + def bind_hw_cc(self, cc_number): + self.hw_cc = cc_number + self.stop_learn() + + def on_learn_context_menu(self, pos): + from PyQt6.QtWidgets import QMenu + if self.hw_cc is None: + return + menu = QMenu(self) + clear_action = menu.addAction("Clear Input Assignment") + action = menu.exec(self.learn_btn.mapToGlobal(pos)) + if action == clear_action: + self.hw_cc = None + self.learn_btn.setText("Learn") + self.learn_btn.setStyleSheet("") + self.cc_input_lbl.setText("") + + def get_state(self): + return { + "uid": self.uid, + "label": self.title_edit.text(), + "cc": self.cc_spin.value(), + "state": self.toggle_state, + "color": self.color_name, + "zone": self.zone, + "center_index": self.center_index, + "zone_index": self.zone_index, + "hw_cc": self.hw_cc, + "output_mode": self.output_mode, + "osc_addr": self.osc_addr_edit.text(), + "trigger_mode": self.trigger_mode, + } + + def set_state(self, state): + if "uid" in state: + self.uid = state["uid"] + self.title_edit.setText(state.get("label", self._label)) + self.cc_spin.setValue(state.get("cc", 1)) + color_name = state.get("color", "Gray") + hex_color = PALETTE_HEX.get(color_name, "transparent") + self.color_name = color_name + self.color_swatch_btn.setStyleSheet(f"background-color: {PALETTE_VIVID.get(color_name, hex_color)}; border-radius: 2px; border: none;") + self.apply_color(hex_color) + self.toggle_state = state.get("state", False) + self._update_btn_style() + self.zone = state.get("zone", None) + self.center_index = state.get("center_index", None) + self.zone_index = state.get("zone_index", None) + self.zone_btn.set_state(self.zone == "left", self.zone == "right") + self.hw_cc = state.get("hw_cc", None) + if self.hw_cc is not None: + self.learn_btn.setText(f"HW: CC{self.hw_cc}") + self.cc_input_lbl.setText(f"In CC{self.hw_cc} [--]") + self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;") + self.osc_addr_edit.setText(state.get("osc_addr", "/play")) + self.set_output_mode(state.get("output_mode", "midi")) + self.set_trigger_mode(state.get("trigger_mode", "momentary")) + + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("MIDI Controller") + self.ready = False + self.selected_strip = None + self.learning_strip = None + self.faders = [] + self.strip_width = 100 + self.spacing = 10 + self.padding = 40 + + self.data = load_presets_file() + if "presets" not in self.data: + self.data = {"__last_used__": None, "presets": self.data} + + central = QWidget() + self.setCentralWidget(central) + main_layout = QVBoxLayout(central) + + # --- Preset manager --- + preset_row = QHBoxLayout() + + self.preset_combo = QComboBox() + self.preset_combo.setMinimumWidth(160) + self.preset_combo.currentTextChanged.connect(self.on_preset_selected) + preset_row.addWidget(self.preset_combo) + + save_btn = QPushButton("Save") + save_btn.setFixedWidth(50) + save_btn.clicked.connect(self.save_preset) + preset_row.addWidget(save_btn) + + save_as_btn = QPushButton("Save As") + save_as_btn.setFixedWidth(60) + save_as_btn.clicked.connect(self.save_preset_as) + preset_row.addWidget(save_as_btn) + + del_btn = QPushButton("Remove") + del_btn.setFixedWidth(65) + del_btn.clicked.connect(self.delete_preset) + preset_row.addWidget(del_btn) + + preset_row.addStretch() + + self.save_status_label = QLabel("") + self.save_status_label.setStyleSheet("color: #00e676; font-style: italic;") + main_layout.addLayout(preset_row) + main_layout.addWidget(self._separator()) + + # --- MIDI I/O row --- + io_row = QHBoxLayout() + io_row.setSpacing(16) + + # Input side + in_col = QVBoxLayout() + in_col.setSpacing(2) + in_top = QHBoxLayout() + self.in_blink = QLabel("●") + self.in_blink.setStyleSheet("color: #444; font-size: 14px;") + self.in_status = QLabel("Not Connected") + self.in_status.setStyleSheet("color: #888;") + in_top.addWidget(self.in_blink) + in_top.addWidget(self.in_status) + in_top.addStretch() + in_col.addLayout(in_top) + in_col.addWidget(QLabel("MIDI Input")) + self.in_combo = QComboBox() + self.in_combo.setFixedWidth(200) + self.available_in_ports = get_available_input_ports() # list of (display_name, index, available) + for display_name, idx, available in self.available_in_ports: + self.in_combo.addItem(display_name) + if not available: + # Gray out the item + item_index = self.in_combo.count() - 1 + self.in_combo.model().item(item_index).setEnabled(False) + self.in_combo.model().item(item_index).setForeground(QColor('#888888')) + self.in_combo.currentTextChanged.connect(self.on_input_port_select) + in_col.addWidget(self.in_combo) + io_row.addLayout(in_col) + + # Output side + out_col = QVBoxLayout() + out_col.setSpacing(2) + out_top = QHBoxLayout() + self.out_blink = QLabel("●") + self.out_blink.setStyleSheet("color: #444; font-size: 14px;") + self.out_status = QLabel("Not Connected") + self.out_status.setStyleSheet("color: #888;") + out_top.addWidget(self.out_blink) + out_top.addWidget(self.out_status) + out_top.addStretch() + out_col.addLayout(out_top) + out_col.addWidget(QLabel("MIDI Output")) + self.port_combo = QComboBox() + self.port_combo.addItems(available_ports) + self.port_combo.setFixedWidth(200) + self.port_combo.currentTextChanged.connect(self.on_port_select) + out_col.addWidget(self.port_combo) + io_row.addLayout(out_col) + + # Transport Output column + t_col = QVBoxLayout() + t_col.setSpacing(2) + t_out_top = QHBoxLayout() + self.t_out_blink = QLabel("●") + self.t_out_blink.setStyleSheet("color: #444; font-size: 14px;") + self.t_out_status = QLabel("Not Connected") + self.t_out_status.setStyleSheet("color: #888;") + t_out_top.addWidget(self.t_out_blink) + t_out_top.addWidget(self.t_out_status) + t_out_top.addStretch() + t_col.addLayout(t_out_top) + t_col.addWidget(QLabel("Transport Output")) + self.t_out_combo = QComboBox() + self.t_out_combo.addItems(available_ports) + self.t_out_combo.setFixedWidth(200) + self.t_out_combo.currentTextChanged.connect(self.on_transport_output_select) + t_col.addWidget(self.t_out_combo) + io_row.addLayout(t_col) + + # OSC column + osc_col = QVBoxLayout() + osc_col.setSpacing(2) + osc_top = QHBoxLayout() + self.osc_blink = QLabel("●") + self.osc_blink.setStyleSheet("color: #444; font-size: 14px;") + self.osc_status = QLabel("Not Listening") + self.osc_status.setStyleSheet("color: #888;") + osc_top.addWidget(self.osc_blink) + osc_top.addWidget(self.osc_status) + osc_top.addStretch() + osc_col.addLayout(osc_top) + # IP address row + osc_ip_row = QHBoxLayout() + osc_ip_row.addWidget(QLabel("DAW IP")) + self.osc_ip_edit = QLineEdit("127.0.0.1") + self.osc_ip_edit.setFixedWidth(110) + self.osc_ip_edit.setPlaceholderText("127.0.0.1") + self.osc_ip_edit.textChanged.connect(self.on_osc_ip_changed) + osc_ip_row.addWidget(self.osc_ip_edit) + osc_ip_row.addStretch() + osc_col.addLayout(osc_ip_row) + + # Port row + osc_port_row = QHBoxLayout() + osc_port_row.addWidget(QLabel("Listen")) + self.osc_port_spin = QSpinBox() + self.osc_port_spin.setMinimum(1024) + self.osc_port_spin.setMaximum(65535) + self.osc_port_spin.setValue(9000) + self.osc_port_spin.setFixedWidth(65) + osc_port_row.addWidget(self.osc_port_spin) + osc_port_row.addSpacing(6) + osc_port_row.addWidget(QLabel("Send")) + self.osc_send_port_spin = QSpinBox() + self.osc_send_port_spin.setMinimum(1024) + self.osc_send_port_spin.setMaximum(65535) + self.osc_send_port_spin.setValue(8000) + self.osc_send_port_spin.setFixedWidth(65) + self.osc_send_port_spin.valueChanged.connect(self.on_osc_send_port_changed) + osc_port_row.addWidget(self.osc_send_port_spin) + osc_port_row.addStretch() + osc_col.addLayout(osc_port_row) + osc_btn_row = QHBoxLayout() + self.osc_toggle_btn = QPushButton("Start OSC") + self.osc_toggle_btn.setFixedWidth(90) + self.osc_toggle_btn.clicked.connect(self.toggle_osc) + osc_btn_row.addWidget(self.osc_toggle_btn) + self.osc_log_btn = QPushButton("Messages") + self.osc_log_btn.setFixedWidth(80) + self.osc_log_btn.setCheckable(True) + self.osc_log_btn.clicked.connect(self.toggle_osc_log) + osc_btn_row.addWidget(self.osc_log_btn) + osc_btn_row.addStretch() + osc_col.addLayout(osc_btn_row) + io_row.addLayout(osc_col) + + io_row.addStretch() + main_layout.addLayout(io_row) + main_layout.addWidget(self._separator()) + + # --- OSC Feedback panel --- + self.osc_panel = QWidget() + self.osc_panel.setStyleSheet("background-color: #252526; border-radius: 4px;") + osc_panel_layout = QVBoxLayout(self.osc_panel) + osc_panel_layout.setContentsMargins(8, 6, 8, 6) + osc_panel_layout.setSpacing(4) + + # Top row — transport state indicators + osc_transport_row = QHBoxLayout() + osc_transport_row.setSpacing(12) + + self.osc_play_lbl = QLabel("■ STOPPED") + self.osc_play_lbl.setStyleSheet("color: #555; font-size: 11px; font-weight: bold;") + osc_transport_row.addWidget(self.osc_play_lbl) + + self.osc_record_lbl = QLabel("● REC") + self.osc_record_lbl.setStyleSheet("color: #555; font-size: 11px; font-weight: bold;") + osc_transport_row.addWidget(self.osc_record_lbl) + + osc_transport_row.addStretch() + + self.osc_tempo_lbl = QLabel("BPM: —") + self.osc_tempo_lbl.setStyleSheet("color: #888; font-size: 11px;") + osc_transport_row.addWidget(self.osc_tempo_lbl) + + self.osc_pos_lbl = QLabel("Pos: —") + self.osc_pos_lbl.setStyleSheet("color: #888; font-size: 11px;") + osc_transport_row.addWidget(self.osc_pos_lbl) + + osc_panel_layout.addLayout(osc_transport_row) + + # Divider + osc_div = QWidget() + osc_div.setFixedHeight(1) + osc_div.setStyleSheet("background-color: #3a3a3a;") + osc_panel_layout.addWidget(osc_div) + + # Bottom row — track and preset info + osc_info_row = QHBoxLayout() + osc_info_row.setSpacing(8) + + track_header = QLabel("TRACK") + track_header.setStyleSheet("color: #555; font-size: 9px; font-weight: bold;") + osc_info_row.addWidget(track_header) + + self.osc_track_lbl = QLabel("—") + self.osc_track_lbl.setStyleSheet("color: #d4d4d4; font-size: 12px; font-weight: bold;") + osc_info_row.addWidget(self.osc_track_lbl) + + osc_info_row.addSpacing(16) + + preset_header = QLabel("PRESET") + preset_header.setStyleSheet("color: #555; font-size: 9px; font-weight: bold;") + osc_info_row.addWidget(preset_header) + + self.osc_preset_lbl = QLabel("—") + self.osc_preset_lbl.setStyleSheet("color: #00e676; font-size: 12px; font-weight: bold;") + osc_info_row.addWidget(self.osc_preset_lbl) + + osc_info_row.addStretch() + osc_panel_layout.addLayout(osc_info_row) + + self.osc_panel.setVisible(False) + main_layout.addWidget(self.osc_panel) + + # OSC In log panel + from PyQt6.QtWidgets import QTextEdit + self.osc_log_panel = QWidget(None, Qt.WindowType.Tool) + self.osc_log_panel.setWindowTitle("OSC In — Incoming") + self.osc_log_panel.setStyleSheet("background-color: #1a1a1a;") + self.osc_log_panel.setFixedWidth(360) + log_layout = QVBoxLayout(self.osc_log_panel) + log_layout.setContentsMargins(6, 6, 6, 6) + log_layout.setSpacing(4) + + log_header = QHBoxLayout() + log_title = QLabel("OSC In — Incoming from REAPER") + log_title.setStyleSheet("color: #00e676; font-weight: bold; font-size: 11px;") + log_header.addWidget(log_title) + log_header.addStretch() + clear_in_btn = QPushButton("Clear") + clear_in_btn.setFixedSize(45, 18) + clear_in_btn.clicked.connect(lambda: self.osc_log.clear()) + log_header.addWidget(clear_in_btn) + log_layout.addLayout(log_header) + + self.osc_log = QTextEdit() + self.osc_log.setReadOnly(True) + self.osc_log.setStyleSheet(""" + QTextEdit { + background-color: #111111; + color: #00e676; + font-family: monospace; + font-size: 10px; + border: none; + } + """) + log_layout.addWidget(self.osc_log) + self.osc_log_panel.setVisible(False) + + # OSC Out log panel + self.osc_out_log_panel = QWidget(None, Qt.WindowType.Tool) + self.osc_out_log_panel.setWindowTitle("OSC Out — Outgoing") + self.osc_out_log_panel.setStyleSheet("background-color: #1a1a1a;") + self.osc_out_log_panel.setFixedWidth(360) + out_log_layout = QVBoxLayout(self.osc_out_log_panel) + out_log_layout.setContentsMargins(6, 6, 6, 6) + out_log_layout.setSpacing(4) + + out_log_header = QHBoxLayout() + out_log_title = QLabel("OSC Out — Sent to REAPER") + out_log_title.setStyleSheet("color: #1e88e5; font-weight: bold; font-size: 11px;") + out_log_header.addWidget(out_log_title) + out_log_header.addStretch() + clear_out_btn = QPushButton("Clear") + clear_out_btn.setFixedSize(45, 18) + clear_out_btn.clicked.connect(lambda: self.osc_out_log.clear()) + out_log_header.addWidget(clear_out_btn) + out_log_layout.addLayout(out_log_header) + + self.osc_out_log = QTextEdit() + self.osc_out_log.setReadOnly(True) + self.osc_out_log.setStyleSheet(""" + QTextEdit { + background-color: #111111; + color: #1e88e5; + font-family: monospace; + font-size: 10px; + border: none; + } + """) + out_log_layout.addWidget(self.osc_out_log) + self.osc_out_log_panel.setVisible(False) + + # --- Preset browser --- + main_layout.addWidget(QLabel("Preset Browser")) + browser_row = QHBoxLayout() + browser_row.setSpacing(8) + + back_btn = QPushButton("◀") + back_btn.setFixedSize(36, 28) + browser_row.addWidget(back_btn) + + self.browser_back_spin = QSpinBox() + self.browser_back_spin.setMinimum(1) + self.browser_back_spin.setMaximum(127) + self.browser_back_spin.setValue(22) + self.browser_back_spin.setFixedWidth(55) + browser_row.addWidget(self.browser_back_spin) + + browser_row.addSpacing(12) + + fwd_btn = QPushButton("▶") + fwd_btn.setFixedSize(36, 28) + browser_row.addWidget(fwd_btn) + + self.browser_fwd_spin = QSpinBox() + self.browser_fwd_spin.setMinimum(1) + self.browser_fwd_spin.setMaximum(127) + self.browser_fwd_spin.setValue(23) + self.browser_fwd_spin.setFixedWidth(55) + browser_row.addWidget(self.browser_fwd_spin) + + browser_row.addStretch() + main_layout.addLayout(browser_row) + + back_btn.clicked.connect(lambda: send_cc(self.browser_back_spin.value(), 127)) + fwd_btn.clicked.connect(lambda: send_cc(self.browser_fwd_spin.value(), 127)) + + main_layout.addWidget(self._separator()) + + # --- Transport --- + self.transports = [] + self.transport_strip_width = 100 + self._capturing_key_for = None + + self.full_transport_row = QHBoxLayout() + self.full_transport_row.setSpacing(self.spacing) + main_layout.addLayout(self.full_transport_row) + + self.transport_zone_left_slot = QHBoxLayout() + self.transport_zone_left_slot.setSpacing(0) + self.full_transport_row.addLayout(self.transport_zone_left_slot) + + self.transport_vsep_left = QFrame() + self.transport_vsep_left.setFrameShape(QFrame.Shape.VLine) + self.transport_vsep_left.setFixedWidth(1) + self.transport_vsep_left.setStyleSheet("background-color: #3a3a3a; border: none;") + self.transport_vsep_left.setVisible(False) + self.full_transport_row.addWidget(self.transport_vsep_left) + + self.transport_row = QHBoxLayout() + self.transport_row.setSpacing(self.spacing) + self.full_transport_row.addLayout(self.transport_row) + + self.transport_vsep_right = QFrame() + self.transport_vsep_right.setFrameShape(QFrame.Shape.VLine) + self.transport_vsep_right.setFixedWidth(1) + self.transport_vsep_right.setStyleSheet("background-color: #3a3a3a; border: none;") + self.transport_vsep_right.setVisible(False) + self.full_transport_row.addWidget(self.transport_vsep_right) + + self.transport_zone_right_slot = QHBoxLayout() + self.transport_zone_right_slot.setSpacing(0) + self.full_transport_row.addLayout(self.transport_zone_right_slot) + + main_layout.addWidget(self._separator()) + + # --- Selected channel label row --- + info_row = QHBoxLayout() + info_row.addStretch() + self.selected_label = QLabel("Selected: None") + self.selected_label.setStyleSheet("color: #00ff88; font-weight: bold;") + self.selected_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + info_row.addWidget(self.selected_label) + info_row.addStretch() + self.save_status_label.setParent(None) + info_row.addWidget(self.save_status_label) + main_layout.addLayout(info_row) + + # --- Faders row --- + self.full_fader_row = QHBoxLayout() + self.full_fader_row.setSpacing(self.spacing) + main_layout.addLayout(self.full_fader_row) + + # Zone Left slot + self.zone_left_slot = QHBoxLayout() + self.zone_left_slot.setSpacing(0) + self.full_fader_row.addLayout(self.zone_left_slot) + + # Left vertical separator — always visible + self.vsep_left = QFrame() + self.vsep_left.setFrameShape(QFrame.Shape.VLine) + self.vsep_left.setFixedWidth(1) + self.vsep_left.setStyleSheet("background-color: #3a3a3a; border: none;") + self.vsep_left.setVisible(False) + self.full_fader_row.addWidget(self.vsep_left) + + # Custom fader row + self.fader_row = QHBoxLayout() + self.fader_row.setSpacing(self.spacing) + self.full_fader_row.addLayout(self.fader_row) + + # Right vertical separator — always visible + self.vsep_right = QFrame() + self.vsep_right.setFrameShape(QFrame.Shape.VLine) + self.vsep_right.setFixedWidth(1) + self.vsep_right.setStyleSheet("background-color: #3a3a3a; border: none;") + self.vsep_right.setVisible(False) + self.full_fader_row.addWidget(self.vsep_right) + + # Zone Right slot + self.zone_right_slot = QHBoxLayout() + self.zone_right_slot.setSpacing(0) + self.full_fader_row.addLayout(self.zone_right_slot) + main_layout.addWidget(self._separator()) + + # --- Toggle --- + self.toggles = [] + self.toggle_strip_width = 100 + + self.full_toggle_row = QHBoxLayout() + self.full_toggle_row.setSpacing(self.spacing) + main_layout.addLayout(self.full_toggle_row) + + self.toggle_zone_left_slot = QHBoxLayout() + self.toggle_zone_left_slot.setSpacing(0) + self.full_toggle_row.addLayout(self.toggle_zone_left_slot) + + self.toggle_vsep_left = QFrame() + self.toggle_vsep_left.setFrameShape(QFrame.Shape.VLine) + self.toggle_vsep_left.setFixedWidth(1) + self.toggle_vsep_left.setStyleSheet("background-color: #3a3a3a; border: none;") + self.toggle_vsep_left.setVisible(False) + self.full_toggle_row.addWidget(self.toggle_vsep_left) + + self.toggle_row = QHBoxLayout() + self.toggle_row.setSpacing(10) + self.full_toggle_row.addLayout(self.toggle_row) + + self.toggle_vsep_right = QFrame() + self.toggle_vsep_right.setFrameShape(QFrame.Shape.VLine) + self.toggle_vsep_right.setFixedWidth(1) + self.toggle_vsep_right.setStyleSheet("background-color: #3a3a3a; border: none;") + self.toggle_vsep_right.setVisible(False) + self.full_toggle_row.addWidget(self.toggle_vsep_right) + + self.toggle_zone_right_slot = QHBoxLayout() + self.toggle_zone_right_slot.setSpacing(0) + self.full_toggle_row.addLayout(self.toggle_zone_right_slot) + + main_layout.addStretch() + + # Add initial 2 faders, 1 toggle, 4 transport strips + self.add_fader() + self.add_fader() + self.add_toggle() + for name, cc in [("Play", 117), ("Stop", 116), ("Record", 118), ("Arm", 119)]: + self.add_transport(name, cc) + + self.ready = True + self.refresh_preset_combo() + self.restore_last_used() + self.refresh_all_id_labels() + self.refresh_port_availability() + + midi_receiver.midi_in_signal.connect(lambda msg: self.blink(self.in_blink)) + midi_receiver.midi_out_signal.connect(lambda: self.blink(self.out_blink)) + midi_receiver.transport_out_signal.connect(lambda: self.blink(self.t_out_blink)) + midi_receiver.hw_cc_signal.connect(self.on_hw_cc) + + # OSC signals + osc_receiver.track_name_signal.connect(self.on_osc_track) + osc_receiver.preset_name_signal.connect(self.on_osc_preset) + osc_receiver.tempo_signal.connect(self.on_osc_tempo) + osc_receiver.position_signal.connect(self.on_osc_position) + osc_receiver.play_signal.connect(self.on_osc_play) + osc_receiver.record_signal.connect(self.on_osc_record) + osc_receiver.raw_message_signal.connect(self.on_osc_raw) + osc_receiver.osc_out_signal.connect(self.on_osc_out_raw) + + def refresh_all_id_labels(self): + # Center faders + center = [f for f in self.faders if not f.zone] + for i, f in enumerate(center): + f.update_id_label(None, i) + # Left zone faders + left = sorted([f for f in self.faders if f.zone == "left"], key=lambda f: f.zone_index or 0) + for i, f in enumerate(left): + f.update_id_label("left", i) + # Right zone faders + right = sorted([f for f in self.faders if f.zone == "right"], key=lambda f: f.zone_index or 0) + for i, f in enumerate(right): + f.update_id_label("right", i) + # Center toggles + t_center = [t for t in self.toggles if not t.zone] + for i, t in enumerate(t_center): + t.update_id_label(None, i) + # Left zone toggles + t_left = sorted([t for t in self.toggles if t.zone == "left"], key=lambda t: t.zone_index or 0) + for i, t in enumerate(t_left): + t.update_id_label("left", i) + # Right zone toggles + t_right = sorted([t for t in self.toggles if t.zone == "right"], key=lambda t: t.zone_index or 0) + for i, t in enumerate(t_right): + t.update_id_label("right", i) + # Transports + tr_center = [t for t in self.transports if not t.zone] + for i, t in enumerate(tr_center): + t.update_id_label(None, i) + tr_left = sorted([t for t in self.transports if t.zone == "left"], key=lambda t: t.zone_index or 0) + for i, t in enumerate(tr_left): + t.update_id_label("left", i) + tr_right = sorted([t for t in self.transports if t.zone == "right"], key=lambda t: t.zone_index or 0) + for i, t in enumerate(tr_right): + t.update_id_label("right", i) + + def on_toggle_zone_change(self, toggle): + if toggle.zone is not None and toggle.center_index is None: + if toggle in self.toggles: + toggle.center_index = self.toggles.index(toggle) + + for layout in [self.toggle_row, self.toggle_zone_left_slot, self.toggle_zone_right_slot]: + for i in range(layout.count()): + item = layout.itemAt(i) + if item and item.widget() and hasattr(item.widget(), 'uid') and item.widget().uid == toggle.uid: + layout.removeWidget(toggle) + toggle.setParent(None) + break + + if toggle.zone == "left": + toggle.zone_index = self.toggle_zone_left_slot.count() - 1 + self.toggle_zone_left_slot.addWidget(toggle) + self.toggle_vsep_left.setVisible(True) + + elif toggle.zone == "right": + toggle.zone_index = self.toggle_zone_right_slot.count() - 1 + self.toggle_zone_right_slot.addWidget(toggle) + self.toggle_vsep_right.setVisible(True) + + else: + idx = toggle.center_index if toggle.center_index is not None else self.toggle_row.count() + idx = min(idx, self.toggle_row.count()) + self.toggle_row.insertWidget(idx, toggle) + toggle.center_index = None + toggle.zone_index = None + self.toggle_vsep_left.setVisible(any(t.zone == "left" for t in self.toggles)) + self.toggle_vsep_right.setVisible(any(t.zone == "right" for t in self.toggles)) + has_left = any(t.zone == "left" for t in self.toggles) + has_right = any(t.zone == "right" for t in self.toggles) + + self.update_window_width() + self.refresh_all_id_labels() + + def on_fader_zone_change(self, fader): + # Save center index before removing from center row + if fader.zone is not None and fader.center_index is None: + # Being zoned for first time — save current center position + if fader in self.faders: + fader.center_index = self.faders.index(fader) + + # Remove by UUID from whichever slot it's in + for layout in [self.fader_row, self.zone_left_slot, self.zone_right_slot]: + for i in range(layout.count()): + item = layout.itemAt(i) + if item and item.widget() and hasattr(item.widget(), 'uid') and item.widget().uid == fader.uid: + layout.removeWidget(fader) + fader.setParent(None) + break + + if fader.zone == "left": + fader.zone_index = self.zone_left_slot.count() - 1 # subtract placeholder + self.zone_left_slot.addWidget(fader) + self.vsep_left.setVisible(True) + + elif fader.zone == "right": + fader.zone_index = self.zone_right_slot.count() - 1 + self.zone_right_slot.addWidget(fader) + self.vsep_right.setVisible(True) + + else: + idx = fader.center_index if fader.center_index is not None else self.fader_row.count() + idx = min(idx, self.fader_row.count()) + self.fader_row.insertWidget(idx, fader) + fader.center_index = None + fader.zone_index = None + self.vsep_left.setVisible(any(f.zone == "left" for f in self.faders)) + self.vsep_right.setVisible(any(f.zone == "right" for f in self.faders)) + has_left = any(f.zone == "left" for f in self.faders) + has_right = any(f.zone == "right" for f in self.faders) + + self.update_window_width() + self.refresh_all_id_labels() + + def select_strip(self, strip): + if self.selected_strip and self.selected_strip != strip: + self.selected_strip.set_selected(False) + self.selected_strip = strip + strip.set_selected(True) + self.selected_label.setText(f"Selected: {strip.title_edit.text()}") + + def add_toggle(self, insert_after=None): + n = len(self.toggles) + 1 + toggle = ToggleWidget(f"Toggle {n}", default_cc=20) + if insert_after is not None and insert_after in self.toggles: + idx = self.toggles.index(insert_after) + self.toggles.insert(idx + 1, toggle) + self.toggle_row.insertWidget(idx + 1, toggle) + else: + self.toggles.append(toggle) + self.toggle_row.addWidget(toggle) + toggle.plus_btn.clicked.connect(lambda: self.add_toggle(insert_after=toggle)) + toggle.minus_btn.clicked.connect(lambda: self.remove_toggle(toggle)) + toggle.on_select_callback = self.select_strip + toggle.on_zone_change_callback = self.on_toggle_zone_change + toggle.learn_btn.clicked.connect(lambda: self.start_learn(toggle)) + self.update_window_width() + self.refresh_all_id_labels() + + def remove_toggle(self, toggle): + if len(self.toggles) <= 1: + return + if self.selected_strip == toggle: + self.selected_strip = None + self.selected_label.setText("Selected: None") + self.toggle_row.removeWidget(toggle) + toggle.deleteLater() + self.toggles.remove(toggle) + self.update_window_width() + self.refresh_all_id_labels() + + def add_fader(self, insert_after=None): + n = len(self.faders) + 1 + fader = FaderWidget(f"Fader {n}", default_cc=1) + if insert_after is not None and insert_after in self.faders: + idx = self.faders.index(insert_after) + self.faders.insert(idx + 1, fader) + self.fader_row.insertWidget(idx + 1, fader) + else: + self.faders.append(fader) + self.fader_row.addWidget(fader) + fader.plus_btn.clicked.connect(lambda: self.add_fader(insert_after=fader)) + fader.minus_btn.clicked.connect(lambda: self.remove_fader(fader)) + fader.learn_btn.clicked.connect(lambda: self.start_learn(fader)) + fader.on_select_callback = self.select_strip + fader.on_zone_change_callback = self.on_fader_zone_change + self.update_window_width() + self.refresh_all_id_labels() + + def remove_fader(self, fader): + if len(self.faders) <= 1: + return + if self.selected_strip == fader: + self.selected_strip = None + self.selected_label.setText("Selected: None") + self.fader_row.removeWidget(fader) + fader.deleteLater() + self.faders.remove(fader) + self.update_window_width() + self.refresh_all_id_labels() + + def update_window_width(self): + nf = len(self.faders) + nt = len(self.toggles) + fader_w = (nf * self.strip_width) + ((nf - 1) * self.spacing) + self.padding + toggle_w = (nt * self.toggle_strip_width) + ((nt - 1) * self.spacing) + self.padding + w = max(fader_w, toggle_w, 300) + self.resize(w, self.height()) + + def _separator(self): + sep = QWidget() + sep.setFixedHeight(1) + sep.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + sep.setStyleSheet("background-color: #3a3a3a;") + return sep + + def refresh_preset_combo(self): + self.preset_combo.blockSignals(True) + self.preset_combo.clear() + self.preset_combo.addItems(sorted(self.data["presets"].keys())) + self.preset_combo.blockSignals(False) + + def restore_last_used(self): + last = self.data.get("__last_used__") + if last and last in self.data["presets"]: + self.preset_combo.blockSignals(True) + self.preset_combo.setCurrentText(last) + self.preset_combo.blockSignals(False) + self.apply_preset(self.data["presets"][last]) + print(f"Restored last used preset: {last}") + + def on_preset_selected(self, name): + if self.ready and name and name in self.data["presets"]: + self.data["__last_used__"] = name + save_presets_file(self.data) + self.apply_preset(self.data["presets"][name]) + + def _show_save_status(self): + from datetime import datetime, date + now = datetime.now() + today = date.today() + time_part = now.strftime('%I:%M:%S %p').lstrip('0') + if now.date() == today: + time_str = f"Today {time_part}" + elif (today - now.date()).days == 1: + time_str = f"Yesterday {time_part}" + else: + time_str = f"{now.strftime('%b')} {now.day} {time_part}" + self.save_status_label.setText(f"[ Saved {time_str} ]") + QTimer.singleShot(4000, lambda: self.save_status_label.setText("")) + + def _build_preset_data(self, name): + return { + "midi_port": self.port_combo.currentText(), + "midi_input": self.in_combo.currentText(), + "transport_port": self.t_out_combo.currentText(), + "faders": [f.get_state() for f in self.faders], + "toggles": [t.get_state() for t in self.toggles], + "transports": [t.get_state() for t in self.transports], + "browser": { + "back_cc": self.browser_back_spin.value(), + "fwd_cc": self.browser_fwd_spin.value() + }, + "osc": { + "ip": self.osc_ip_edit.text(), + "listen_port": self.osc_port_spin.value(), + "send_port": self.osc_send_port_spin.value(), + } + } + + def save_preset(self): + name = self.preset_combo.currentText() + if not name or name not in self.data["presets"]: + # No current preset — fall through to Save As + self.save_preset_as() + return + self.data["presets"][name] = self._build_preset_data(name) + self.data["__last_used__"] = name + save_presets_file(self.data) + self._show_save_status() + print(f"Saved preset: {name}") + + def save_preset_as(self): + name, ok = QInputDialog.getText(self, "Save As", "Preset name:") + if not ok or not name.strip(): + return + name = name.strip() + self.data["presets"][name] = self._build_preset_data(name) + self.data["__last_used__"] = name + save_presets_file(self.data) + self.refresh_preset_combo() + self.preset_combo.setCurrentText(name) + self._show_save_status() + print(f"Saved preset as: {name}") + + def delete_preset(self): + from PyQt6.QtWidgets import QMessageBox + name = self.preset_combo.currentText() + if not name or name not in self.data["presets"]: + return + msg = QMessageBox(self) + msg.setWindowTitle("Remove Preset") + msg.setText(f'Are you sure you want to remove "{name}"?') + msg.setIcon(QMessageBox.Icon.NoIcon) + msg.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No) + reply = msg.exec() + if reply != QMessageBox.StandardButton.Yes: + return + del self.data["presets"][name] + if self.data["__last_used__"] == name: + self.data["__last_used__"] = None + save_presets_file(self.data) + self.refresh_preset_combo() + print(f"Deleted preset: {name}") + + def apply_preset(self, preset): + port_name = preset.get("midi_port", "") + if port_name in available_ports: + self.port_combo.setCurrentText(port_name) + + transport_port = preset.get("transport_port", "") + if transport_port in available_ports: + self.t_out_combo.setCurrentText(transport_port) + + midi_input = preset.get("midi_input", "") + input_names = [entry[0] for entry in self.available_in_ports] + if midi_input in input_names: + self.in_combo.setCurrentText(midi_input) + fader_states = preset.get("faders", []) + while len(self.faders) < len(fader_states): + self.add_fader() + while len(self.faders) > len(fader_states): + widget = self.faders.pop() + self.fader_row.removeWidget(widget) + widget.deleteLater() + self.update_window_width() + for i, widget in enumerate(self.faders): + widget.set_state(fader_states[i]) + + # First place all center faders in their saved positions + center_faders = sorted( + [f for f in self.faders if not f.zone], + key=lambda f: f.center_index if f.center_index is not None else 999 + ) + for f in center_faders: + self.fader_row.removeWidget(f) + f.setParent(None) + for f in center_faders: + self.fader_row.addWidget(f) + + # Then place zoned faders in zone order + left_faders = sorted( + [f for f in self.faders if f.zone == "left"], + key=lambda f: f.zone_index if f.zone_index is not None else 999 + ) + right_faders = sorted( + [f for f in self.faders if f.zone == "right"], + key=lambda f: f.zone_index if f.zone_index is not None else 999 + ) + for f in left_faders: + self.fader_row.removeWidget(f) + f.setParent(None) + self.zone_left_slot.addWidget(f) + for f in right_faders: + self.fader_row.removeWidget(f) + f.setParent(None) + self.zone_right_slot.addWidget(f) + + self.update_window_width() + # Migration: old presets have 'toggle' (singular), new have 'toggles' (list) + if "toggles" in preset: + toggle_states = preset["toggles"] + elif "toggle" in preset: + toggle_states = [preset["toggle"]] + else: + toggle_states = [] + + if not toggle_states: + if len(self.toggles) == 0: + self.add_toggle() + else: + while len(self.toggles) < len(toggle_states): + self.add_toggle() + while len(self.toggles) > len(toggle_states): + widget = self.toggles.pop() + self.toggle_row.removeWidget(widget) + widget.deleteLater() + self.update_window_width() + for i, widget in enumerate(self.toggles): + widget.set_state(toggle_states[i]) + + # Place center toggles + center_toggles = sorted( + [t for t in self.toggles if not t.zone], + key=lambda t: t.center_index if t.center_index is not None else 999 + ) + for t in center_toggles: + self.toggle_row.removeWidget(t) + t.setParent(None) + for t in center_toggles: + self.toggle_row.addWidget(t) + + # Place zoned toggles + left_toggles = sorted( + [t for t in self.toggles if t.zone == "left"], + key=lambda t: t.zone_index if t.zone_index is not None else 999 + ) + right_toggles = sorted( + [t for t in self.toggles if t.zone == "right"], + key=lambda t: t.zone_index if t.zone_index is not None else 999 + ) + for t in left_toggles: + self.toggle_row.removeWidget(t) + t.setParent(None) + self.toggle_zone_left_slot.addWidget(t) + for t in right_toggles: + self.toggle_row.removeWidget(t) + t.setParent(None) + self.toggle_zone_right_slot.addWidget(t) + + + browser = preset.get("browser", {}) + self.browser_back_spin.setValue(browser.get("back_cc", 22)) + self.browser_fwd_spin.setValue(browser.get("fwd_cc", 23)) + + transport_states = preset.get("transports", []) + for i, t in enumerate(self.transports): + if i < len(transport_states): + t.set_state(transport_states[i]) + + osc = preset.get("osc", {}) + if osc: + self.osc_ip_edit.setText(osc.get("ip", "127.0.0.1")) + self.osc_port_spin.setValue(osc.get("listen_port", 9000)) + self.osc_send_port_spin.setValue(osc.get("send_port", 8000)) + + def on_input_port_select(self, display_name): + if not display_name: + return + port_entry = next((entry for entry in self.available_in_ports if entry[0] == display_name), None) + if port_entry is None: + return + display_name, original_index, available = port_entry + if not available: + self.in_status.setText("In use by another program") + self.in_status.setStyleSheet("color: #e67e22;") + return + if hasattr(self, "midi_in"): + close_midi_input_port(self.midi_in) + del self.midi_in + try: + + self.midi_in = open_midi_input_port(original_index, self.on_midi_in) + self.in_status.setText("Connected") + self.in_status.setStyleSheet("color: #27ae60;") + print(f"Opened input port: {display_name}") + except Exception as e: + print(f"Failed to open port {display_name}: {e}") + self.in_status.setText("Failed to open") + self.in_status.setStyleSheet("color: #c0392b;") + + def on_hw_cc(self, fader_uid, value): + for fader in self.faders: + if fader.uid == fader_uid: + out_cc = fader.cc_spin.value() + print(f"[convert] HW CC{fader.hw_cc} ({value}) → CC{out_cc} ({value})") + fader.fader.blockSignals(True) + fader.fader.setValue(value) + fader.fader.blockSignals(False) + fader.cc_input_lbl.setText(f"In CC{fader.hw_cc} [{value}]") + fader.cc_output_lbl.setText(f"Out CC{out_cc} [{value}]") + fader.readout.setText(f"CC {out_cc} → {value}") + fader.last_sent = value + fader.prev_position = value + send_cc(out_cc, value) + return + for t in self.transports: + if t.uid == fader_uid: + out_cc = t.cc_spin.value() + should_be_on = value > 63 + if t.toggle_state != should_be_on: + t.toggle_state = should_be_on + out_val = 127 if should_be_on else 0 + send_transport_cc(out_cc, out_val) + t.cc_input_lbl.setText(f"In CC{t.hw_cc} [{value}]") + t.cc_output_lbl.setText(f"Out CC{out_cc} [{out_val}]") + t._update_btn_style() + return + should_be_on = value > 63 + if toggle.toggle_state != should_be_on: + toggle.toggle_state = should_be_on + out_cc = toggle.cc_spin.value() + out_val = 127 if should_be_on else 0 + send_cc(out_cc, out_val) + toggle.cc_input_lbl.setText(f"In CC{toggle.hw_cc} [{value}]") + toggle.cc_output_lbl.setText(f"Out CC{out_cc} [{out_val}]") + toggle._update_btn_style() + return + + def start_learn(self, fader): + if self.learning_strip and self.learning_strip != fader: + self.learning_strip.stop_learn() + self.learning_strip = fader + fader.start_learn() + + def on_midi_in(self, event, data=None): + message, _ = event + status = message[0] & 0xF0 + cc_num = message[1] if len(message) >= 2 else None + cc_val = message[2] if len(message) >= 3 else None + + # Check if this CC is bound to a fader, toggle, or transport + bound_fader = None + bound_transport = None + if status == 0xB0: + for fader in self.faders: + if fader.hw_cc == cc_num: + bound_fader = fader + break + if not bound_fader: + for toggle in self.toggles: + if hasattr(toggle, 'hw_cc') and toggle.hw_cc == cc_num: + bound_fader = toggle + break + if not bound_fader: + for t in self.transports: + if hasattr(t, 'hw_cc') and t.hw_cc == cc_num: + bound_transport = t + break + + # Learning mode — bind CC to strip, don't forward + if status == 0xB0 and self.learning_strip: + self.learning_strip.bind_hw_cc(cc_num) + self.learning_strip = None + return + + if bound_transport: + # Transport CC — only goes to transport_midi_out + print(f"[transport hw] CC{cc_num} val={cc_val} → uid={bound_transport.uid}") + midi_receiver.hw_cc_signal.emit(bound_transport.uid, cc_val) + elif bound_fader: + # Regular fader/toggle — goes to midi_out + print(f"[hw] CC{cc_num} val={cc_val} → uid={bound_fader.uid} out_cc={bound_fader.cc_spin.value()}") + midi_receiver.hw_cc_signal.emit(bound_fader.uid, cc_val) + else: + # Not bound — pass through normally + print(f"[passthrough] CC{cc_num} val={cc_val}") + if midi_out.is_port_open(): + midi_out.send_message(message) + + midi_receiver.midi_in_signal.emit(list(message)) + + def blink(self, led): + led.setStyleSheet("color: #2ecc71; font-size: 14px;") + QTimer.singleShot(80, lambda: led.setStyleSheet("color: #444; font-size: 14px;")) + + def refresh_port_availability(self): + """Gray out ports already in use by another dropdown.""" + out_port = self.port_combo.currentText() + t_port = self.t_out_combo.currentText() + + # Output combo — gray out transport port + for i in range(self.port_combo.model().rowCount()): + item = self.port_combo.model().item(i) + # Strip any existing [in use] tag before comparing + base = item.text().replace(" [in use]", "") + if base == t_port and base != out_port: + item.setText(f"{base} [in use]") + item.setEnabled(False) + item.setForeground(QColor('#888888')) + else: + item.setText(base) + item.setEnabled(True) + item.setForeground(QColor('#ffffff')) + + # Transport combo — gray out output port + for i in range(self.t_out_combo.model().rowCount()): + item = self.t_out_combo.model().item(i) + base = item.text().replace(" [in use]", "") + if base == out_port and base != t_port: + item.setText(f"{base} [in use]") + item.setEnabled(False) + item.setForeground(QColor('#888888')) + else: + item.setText(base) + item.setEnabled(True) + item.setForeground(QColor('#ffffff')) + + def on_port_select(self, port_name): + if midi_out.is_port_open(): + midi_out.close_port() + port_index = available_ports.index(port_name) + midi_out.open_port(port_index) + self.out_status.setText("Connected") + self.out_status.setStyleSheet("color: #27ae60;") + print(f"Opened output port: {port_name}") + self.refresh_port_availability() + + def on_transport_output_select(self, port_name): + if not port_name: + return + if transport_midi_out.is_port_open(): + transport_midi_out.close_port() + port_index = available_ports.index(port_name) + transport_midi_out.open_port(port_index) + self.t_out_status.setText("Connected") + self.t_out_status.setStyleSheet("color: #27ae60;") + print(f"Opened transport output port: {port_name}") + self.refresh_port_availability() + + def add_transport(self, label, default_cc): + t = TransportWidget(label, default_cc) + t.on_select_callback = self.select_strip + t.learn_btn.clicked.connect(lambda: self.start_learn(t)) + t.on_zone_change_callback = self.on_transport_zone_change + self.transports.append(t) + self.transport_row.addWidget(t) + self.refresh_all_id_labels() + + def on_transport_zone_change(self, transport): + if transport.zone is not None and transport.center_index is None: + if transport in self.transports: + transport.center_index = self.transports.index(transport) + for layout in [self.transport_row, self.transport_zone_left_slot, self.transport_zone_right_slot]: + for i in range(layout.count()): + item = layout.itemAt(i) + if item and item.widget() and hasattr(item.widget(), 'uid') and item.widget().uid == transport.uid: + layout.removeWidget(transport) + transport.setParent(None) + break + if transport.zone == "left": + transport.zone_index = self.transport_zone_left_slot.count() + self.transport_zone_left_slot.addWidget(transport) + self.transport_vsep_left.setVisible(True) + elif transport.zone == "right": + transport.zone_index = self.transport_zone_right_slot.count() + self.transport_zone_right_slot.addWidget(transport) + self.transport_vsep_right.setVisible(True) + else: + idx = transport.center_index if transport.center_index is not None else self.transport_row.count() + self.transport_row.insertWidget(min(idx, self.transport_row.count()), transport) + transport.center_index = None + transport.zone_index = None + self.transport_vsep_left.setVisible(any(t.zone == "left" for t in self.transports)) + self.transport_vsep_right.setVisible(any(t.zone == "right" for t in self.transports)) + self.refresh_all_id_labels() + + def toggle_osc_log(self, checked): + self.osc_log_panel.setVisible(checked) + self.osc_out_log_panel.setVisible(checked) + self._position_osc_log() + + def _position_osc_log(self): + geo = self.frameGeometry() + h = geo.height() + # In panel to the right + self.osc_log_panel.setFixedHeight(h) + self.osc_log_panel.move(geo.right() + 4, geo.top()) + # Out panel stacked below in panel + self.osc_out_log_panel.setFixedWidth(360) + self.osc_out_log_panel.setFixedHeight(h // 2) + self.osc_out_log_panel.move(geo.right() + 4, geo.top() + h // 2 + 4) + # Adjust in panel height + self.osc_log_panel.setFixedHeight(h // 2 - 4) + + def resizeEvent(self, event): + super().resizeEvent(event) + if self.osc_log_panel.isVisible(): + self._position_osc_log() + + def moveEvent(self, event): + super().moveEvent(event) + if self.osc_log_panel.isVisible(): + self._position_osc_log() + + def on_osc_raw(self, addr, val): + from datetime import datetime + ts = datetime.now().strftime("%H:%M:%S") + line = f"{ts} {addr} {val}" + self.osc_log.append(line) + doc = self.osc_log.document() + if doc.blockCount() > 200: + cursor = self.osc_log.textCursor() + cursor.movePosition(cursor.MoveOperation.Start) + cursor.select(cursor.SelectionType.LineUnderCursor) + cursor.removeSelectedText() + cursor.deleteChar() + + def on_osc_out_raw(self, addr, val): + from datetime import datetime + ts = datetime.now().strftime("%H:%M:%S") + line = f"{ts} {addr} {val}" + self.osc_out_log.append(line) + doc = self.osc_out_log.document() + if doc.blockCount() > 200: + cursor = self.osc_out_log.textCursor() + cursor.movePosition(cursor.MoveOperation.Start) + cursor.select(cursor.SelectionType.LineUnderCursor) + cursor.removeSelectedText() + cursor.deleteChar() + + def on_osc_ip_changed(self, ip): + global _osc_client, _osc_send_ip + _osc_client = None + _osc_send_ip = ip.strip() or "127.0.0.1" + + def on_osc_send_port_changed(self, port): + global _osc_client, _osc_send_port + _osc_client = None + _osc_send_port = port + + def toggle_osc(self): + if _osc_server is not None: + stop_osc_server() + self.osc_status.setText("Not Listening") + self.osc_status.setStyleSheet("color: #888;") + self.osc_toggle_btn.setText("Start OSC") + self.osc_blink.setStyleSheet("color: #444; font-size: 14px;") + self.osc_panel.setVisible(False) + else: + port = self.osc_port_spin.value() + ok = start_osc_server(port) + if ok: + self.osc_status.setText(f"Listening :{port}") + self.osc_status.setStyleSheet("color: #27ae60;") + self.osc_toggle_btn.setText("Stop OSC") + self.osc_blink.setStyleSheet("color: #2ecc71; font-size: 14px;") + self.osc_panel.setVisible(True) + else: + self.osc_status.setText("Failed") + self.osc_status.setStyleSheet("color: #c0392b;") + + def on_osc_track(self, name): + self.osc_track_lbl.setText(name) + self.blink(self.osc_blink) + + def on_osc_preset(self, name): + self.osc_preset_lbl.setText(name) + self.blink(self.osc_blink) + + def on_osc_tempo(self, bpm): + self.osc_tempo_lbl.setText(f"BPM: {bpm:.1f}") + + def on_osc_position(self, pos): + self.osc_pos_lbl.setText(f"Pos: {pos}") + + def on_osc_play(self, state): + if state: + self.osc_play_lbl.setText("▶ PLAYING") + self.osc_play_lbl.setStyleSheet("color: #00e676; font-size: 11px; font-weight: bold;") + else: + self.osc_play_lbl.setText("■ STOPPED") + self.osc_play_lbl.setStyleSheet("color: #555; font-size: 11px; font-weight: bold;") + + def on_osc_record(self, state): + if state: + self.osc_record_lbl.setText("● REC") + self.osc_record_lbl.setStyleSheet("color: #e53935; font-size: 11px; font-weight: bold;") + else: + self.osc_record_lbl.setText("● REC") + self.osc_record_lbl.setStyleSheet("color: #555; font-size: 11px; font-weight: bold;") + + def fire_transport(self, name): + for t in self.transports: + if t._label == name: + t.on_click() + break + + def capture_key(self, name): + pass + + def keyPressEvent(self, event): + super().keyPressEvent(event) + + def keyPressEvent(self, event): + if self._capturing_key_for: + name = self._capturing_key_for + entry = self.transport_buttons[name] + key = event.key() + modifiers = event.modifiers() + if modifiers == Qt.KeyboardModifier.ControlModifier: + entry["modifier"] = Qt.KeyboardModifier.ControlModifier + else: + entry["modifier"] = Qt.KeyboardModifier.NoModifier + entry["key"] = key + self._capturing_key_for = None + else: + key = event.key() + modifiers = event.modifiers() + for name, entry in self.transport_buttons.items(): + if entry["key"] is None: + continue + required_modifier = entry.get("modifier", Qt.KeyboardModifier.NoModifier) + if key == entry["key"] and modifiers == required_modifier: + self.fire_transport(name) + return + super().keyPressEvent(event) + + +app = QApplication(sys.argv) +app.setStyle("Fusion") + +# VS Code dark theme palette +dark_palette = app.palette() +from PyQt6.QtGui import QPalette +from PyQt6.QtCore import Qt +dark_palette.setColor(QPalette.ColorRole.Window, QColor('#1e1e1e')) +dark_palette.setColor(QPalette.ColorRole.WindowText, QColor('#d4d4d4')) +dark_palette.setColor(QPalette.ColorRole.Base, QColor('#252526')) +dark_palette.setColor(QPalette.ColorRole.AlternateBase, QColor('#1e1e1e')) +dark_palette.setColor(QPalette.ColorRole.ToolTipBase, QColor('#1e1e1e')) +dark_palette.setColor(QPalette.ColorRole.ToolTipText, QColor('#d4d4d4')) +dark_palette.setColor(QPalette.ColorRole.Text, QColor('#d4d4d4')) +dark_palette.setColor(QPalette.ColorRole.Button, QColor('#3c3c3c')) +dark_palette.setColor(QPalette.ColorRole.ButtonText, QColor('#d4d4d4')) +dark_palette.setColor(QPalette.ColorRole.BrightText, QColor('#ffffff')) +dark_palette.setColor(QPalette.ColorRole.Highlight, QColor('#264f78')) +dark_palette.setColor(QPalette.ColorRole.HighlightedText, QColor('#ffffff')) +app.setPalette(dark_palette) +window = MainWindow() +set_window(window) +window.show() +sys.exit(app.exec()) \ No newline at end of file diff --git a/midi_ports.py b/midi_ports.py new file mode 100644 index 0000000..128a317 --- /dev/null +++ b/midi_ports.py @@ -0,0 +1,33 @@ +import rtmidi + +def get_available_input_ports(): + """Returns list of (display_name, original_index, available) for all ports.""" + try: + all_ports = rtmidi.MidiIn().get_ports() + except: + return [] + + result = [] + + for i, name in enumerate(all_ports): + try: + test = rtmidi.MidiIn() + test.open_port(i) + test.close_port() + del test + result.append((name, i, True)) + except: + print(f"Port in use, skipping: {name}") + result.append((f"{name} [in use by another program]", i, False)) + + return result + +def open_midi_input_port(original_index, callback): + midi_in = rtmidi.MidiIn() + midi_in.open_port(original_index) + midi_in.set_callback(callback) + return midi_in + +def close_midi_input_port(midi_in): + if midi_in and midi_in.is_port_open(): + midi_in.close_port() \ No newline at end of file diff --git a/midi_receiver.py b/midi_receiver.py new file mode 100644 index 0000000..eff0c72 --- /dev/null +++ b/midi_receiver.py @@ -0,0 +1,11 @@ +from PyQt6.QtCore import QObject, pyqtSignal + +class MidiReceiver(QObject): + midi_in_signal = pyqtSignal(list) + midi_out_signal = pyqtSignal() + transport_out_signal = pyqtSignal() + hw_cc_signal = pyqtSignal(str, int) + +midi_receiver = MidiReceiver() + + diff --git a/midi_sender.py b/midi_sender.py new file mode 100644 index 0000000..85177d2 --- /dev/null +++ b/midi_sender.py @@ -0,0 +1,25 @@ +import rtmidi +from midi_receiver import midi_receiver + + +MIDI_CH = 0 +TRANSPORT_MIDI_CH = 0 + +midi_out = rtmidi.MidiOut() +transport_midi_out = rtmidi.MidiOut() + +available_ports = midi_out.get_ports() +print("Available MIDI ports:", available_ports) + + +def send_cc(cc_number, value): + if midi_out.is_port_open(): + midi_out.send_message([0xB0 | MIDI_CH, cc_number, value]) + midi_receiver.midi_out_signal.emit() + #print([0xB0 | MIDI_CH, cc_number, value]) + + +def send_transport_cc(cc_number, value): + if transport_midi_out.is_port_open(): + transport_midi_out.send_message([0xB0 | TRANSPORT_MIDI_CH, cc_number, value]) + midi_receiver.transport_out_signal.emit() \ No newline at end of file diff --git a/palette.py b/palette.py new file mode 100644 index 0000000..cc752b5 --- /dev/null +++ b/palette.py @@ -0,0 +1,45 @@ +PALETTE = [ + ("Red", "#e53935"), + ("Orange", "#fb8c00"), + ("Yellow", "#fdd835"), + ("Green", "#43a047"), + ("Teal", "#00897b"), + ("Blue", "#1e88e5"), + ("Purple", "#8e24aa"), + ("Pink", "#e91e63"), + ("White", "#f5f5f5"), + ("Gray", "#4a5568"), +] + +PALETTE_NAMES = [p[0] for p in PALETTE] +PALETTE_HEX = {p[0]: p[1] for p in PALETTE} + +PALETTE_VIVID = { + "Red": "#7f0000", + "Orange": "#7f3000", + "Yellow": "#7f6000", + "Green": "#1b5e20", + "Teal": "#004d40", + "Blue": "#0d2b6e", + "Purple": "#4a0072", + "Pink": "#7f004d", + "White": "#9e9e9e", + "Gray": "#263238", +} + +def lighten_hex(hex_color, amount=0): + hex_color = hex_color.lstrip("#") + r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16) + r = min(255, r + amount) + g = min(255, g + amount) + b = min(255, b + amount) + return f"#{r:02x}{g:02x}{b:02x}" + + +def darken_hex(hex_color, amount=60): + hex_color = hex_color.lstrip("#") + r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16) + r = max(0, r - amount) + g = max(0, g - amount) + b = max(0, b - amount) + return f"#{r:02x}{g:02x}{b:02x}" \ No newline at end of file diff --git a/presets.py b/presets.py new file mode 100644 index 0000000..c8c8bf1 --- /dev/null +++ b/presets.py @@ -0,0 +1,17 @@ +import json +import os + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +PRESETS_DIR = os.path.join(BASE_DIR, "presets") +PRESETS_FILE = os.path.join(PRESETS_DIR, "presets.json") +os.makedirs(PRESETS_DIR, exist_ok=True) + +def load_presets_file(): + if os.path.exists(PRESETS_FILE): + with open(PRESETS_FILE, "r") as f: + return json.load(f) + return {"__last_used__": None, "presets": {}} + +def save_presets_file(data): + with open(PRESETS_FILE, "w") as f: + json.dump(data, f, indent=2) \ No newline at end of file diff --git a/presets/presets.json b/presets/presets.json new file mode 100644 index 0000000..958338d --- /dev/null +++ b/presets/presets.json @@ -0,0 +1,105 @@ +{ + "__last_used__": "test1", + "presets": { + "test1": { + "midi_port": "Microsoft GS Wavetable Synth 0", + "midi_input": "MPKmini2 1", + "transport_port": "Microsoft GS Wavetable Synth 0", + "faders": [ + { + "uid": "5ca2258a-24da-41b1-8d4a-464b52fc620a", + "label": "Fader 1", + "cc": 1, + "value": 102, + "color": "Green", + "pickup": false, + "last_sent": 102, + "hw_cc": null, + "zone": null, + "center_index": null, + "zone_index": null + } + ], + "toggles": [ + { + "uid": "2e87ec73-fcdc-4e7f-841b-9b701d03a576", + "label": "Toggle 1", + "cc": 20, + "state": false, + "color": "Blue", + "zone": null, + "center_index": null, + "zone_index": null, + "hw_cc": null + } + ], + "transports": [ + { + "uid": "c5e25c55-f0b3-464f-aba9-7ff44963543b", + "label": "Play", + "cc": 117, + "state": false, + "color": "Green", + "zone": null, + "center_index": null, + "zone_index": null, + "hw_cc": null, + "output_mode": "osc", + "osc_addr": "/play", + "trigger_mode": "toggle" + }, + { + "uid": "2b2b4e59-643a-44dc-ad54-71f565ad3bc7", + "label": "Stop", + "cc": 116, + "state": false, + "color": "Yellow", + "zone": null, + "center_index": null, + "zone_index": null, + "hw_cc": null, + "output_mode": "osc", + "osc_addr": "/play", + "trigger_mode": "toggle" + }, + { + "uid": "b2b81948-59c9-4b1f-b185-d7ab244a195b", + "label": "Record", + "cc": 118, + "state": false, + "color": "Red", + "zone": null, + "center_index": null, + "zone_index": null, + "hw_cc": null, + "output_mode": "osc", + "osc_addr": "/play", + "trigger_mode": "toggle" + }, + { + "uid": "e27dec15-9de0-4eb6-a776-d3d4bc216cb8", + "label": "Arm", + "cc": 119, + "state": false, + "color": "Red", + "zone": null, + "center_index": null, + "zone_index": null, + "hw_cc": null, + "output_mode": "osc", + "osc_addr": "/play", + "trigger_mode": "toggle" + } + ], + "browser": { + "back_cc": 22, + "fwd_cc": 23 + }, + "osc": { + "ip": "127.0.0.1", + "listen_port": 9000, + "send_port": 8000 + } + } + } +} \ No newline at end of file diff --git a/styles.py b/styles.py new file mode 100644 index 0000000..971c42f --- /dev/null +++ b/styles.py @@ -0,0 +1,32 @@ +FADER_STYLE = """ + QSlider::groove:vertical { + background: #1e1e1e; + width: 8px; + border-radius: 4px; + } + QSlider::handle:vertical { + background: #bbbbbb; + border: 1px solid #222222; + height: 39px; + width: 24px; + margin: 0 -8px; + border-radius: 3px; + } + QSlider::handle:vertical:hover { + background: #dddddd; + border: 1px solid #111111; + } +""" + +TITLE_STYLE = """ + QLineEdit { + background-color: #000000; + border: none; + color: #ffffff; + font-weight: bold; + padding: 2px; + } + QLineEdit:focus { + border: 1px solid #555; + } +""" \ No newline at end of file diff --git a/zonebutton.py b/zonebutton.py new file mode 100644 index 0000000..a43f88f --- /dev/null +++ b/zonebutton.py @@ -0,0 +1,88 @@ +from PyQt6.QtWidgets import QPushButton +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QPainter, QColor, QFont + + +class ZoneButton(QPushButton): + """Split L/R zone button. Left half = L, Right half = R.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.left_active = False + self.right_active = False + self.setFixedHeight(20) + self.left_callback = None + self.right_callback = None + self._update_style() + + def mousePressEvent(self, event): + if event.button() == Qt.MouseButton.LeftButton: + if event.position().x() < self.width() / 2: + self.left_active = not self.left_active + if self.left_active: + self.right_active = False + if self.left_callback: + self.left_callback(self.left_active) + else: + self.right_active = not self.right_active + if self.right_active: + self.left_active = False + if self.right_callback: + self.right_callback(self.right_active) + + self._update_style() + + def set_state(self, left, right): + self.left_active = left + self.right_active = right + self._update_style() + + def _update_style(self): + l_color = "#00c853" if self.left_active else "#2a2a2a" + r_color = "#00c853" if self.right_active else "#2a2a2a" + + self.setStyleSheet(f""" + QPushButton {{ + background: qlineargradient( + x1:0, y1:0, x2:1, y2:0, + stop:0 {l_color}, + stop:0.499 {l_color}, + stop:0.5 {r_color}, + stop:1 {r_color} + ); + border-top-left-radius: 4px; + border-bottom-left-radius: 4px; + border-top-right-radius: 4px; + border-bottom-right-radius: 4px; + border: 1px solid #444; + color: #aaaaaa; + font-size: 9px; + font-weight: bold; + }} + """) + + self.setText("") + self.update() + + def paintEvent(self, event): + super().paintEvent(event) + + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + mid = self.width() // 2 + + painter.setPen(QColor("#666666")) + painter.drawLine(mid, 3, mid, self.height() - 3) + + painter.setPen(QColor("#ffffff")) + + font = QFont() + font.setPointSize(7) + font.setBold(True) + painter.setFont(font) + + painter.drawText(0, 0, mid, self.height(), 0x0084, "L") + painter.drawText(mid, 0, mid, self.height(), 0x0084, "R") + + painter.end() \ No newline at end of file