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())