diff --git a/faderwidget.py b/faderwidget.py new file mode 100644 index 0000000..14f9e35 --- /dev/null +++ b/faderwidget.py @@ -0,0 +1,389 @@ +import uuid + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QSlider, + QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy, + QPushButton, QCheckBox +) +from PyQt6.QtCore import Qt, QEvent, QTimer + +from palette import ( + PALETTE_NAMES, + PALETTE_HEX, + PALETTE_VIVID, + lighten_hex, + darken_hex, +) +from styles import FADER_STYLE, TITLE_STYLE +from zonebutton import ZoneButton +from colorswatchpopup import ColorSwatchPopup +from midi_sender import send_cc + + +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;") \ No newline at end of file diff --git a/main.py b/main.py index 47d4c7b..270f1c1 100644 --- a/main.py +++ b/main.py @@ -15,6 +15,10 @@ from pythonosc import dispatcher as osc_dispatcher from pythonosc import osc_server import threading +from togglewidget import ToggleWidget +from faderwidget import FaderWidget +from transportwidget import TransportWidget + from palette import ( PALETTE, PALETTE_NAMES, @@ -52,55 +56,17 @@ from midi_ports import ( open_midi_input_port, close_midi_input_port, ) +from osc_receiver import osc_receiver -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}") - +from osc_sender import ( + send_osc_message, + set_osc_target, +) 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 @@ -166,1004 +132,11 @@ def stop_osc_server(): -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): @@ -2350,14 +1323,10 @@ class MainWindow(QMainWindow): 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" + set_osc_target(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 + set_osc_target(port=port) def toggle_osc(self): if _osc_server is not None: diff --git a/osc_manager.py b/osc_manager.py new file mode 100644 index 0000000..e69de29 diff --git a/osc_receiver.py b/osc_receiver.py new file mode 100644 index 0000000..8c1386d --- /dev/null +++ b/osc_receiver.py @@ -0,0 +1,14 @@ +from PyQt6.QtCore import QObject, pyqtSignal + +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) + osc_out_signal = pyqtSignal(str, str) + + +osc_receiver = OSCReceiver() \ No newline at end of file diff --git a/osc_sender.py b/osc_sender.py new file mode 100644 index 0000000..29a45d5 --- /dev/null +++ b/osc_sender.py @@ -0,0 +1,39 @@ +from pythonosc import udp_client +from midi_receiver import midi_receiver +from osc_receiver import osc_receiver + + +_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: + _osc_client = udp_client.SimpleUDPClient(_osc_send_ip, _osc_send_port) + + return _osc_client + + +def set_osc_target(ip=None, port=None): + global _osc_client, _osc_send_ip, _osc_send_port + + if ip is not None: + _osc_send_ip = ip + + if port is not None: + _osc_send_port = port + + _osc_client = None + + +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}") \ No newline at end of file diff --git a/presets/presets.json b/presets/presets.json index 958338d..e94b86a 100644 --- a/presets/presets.json +++ b/presets/presets.json @@ -1,105 +1,131 @@ -{ - "__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 - } - } - } +{ + "__last_used__": "test1", + "presets": { + "test1": { + "midi_port": "IAC Driver Bus 1", + "midi_input": "VMPK Output", + "transport_port": "IAC Driver Bus 1", + "faders": [ + { + "uid": "5ca2258a-24da-41b1-8d4a-464b52fc620a", + "label": "Fader 1", + "cc": 1, + "value": 127, + "color": "Green", + "pickup": false, + "last_sent": 127, + "hw_cc": 1, + "zone": null, + "center_index": null, + "zone_index": null + }, + { + "uid": "9dd10748-366b-4305-80ae-70b087bd4900", + "label": "Fader 2", + "cc": 1, + "value": 64, + "color": "Gray", + "pickup": false, + "last_sent": 64, + "hw_cc": null, + "zone": null, + "center_index": null, + "zone_index": null + }, + { + "uid": "a5b03539-5a4b-44cf-9e90-0d97c4c18292", + "label": "Fader 3", + "cc": 1, + "value": 64, + "color": "Gray", + "pickup": false, + "last_sent": 64, + "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/togglewidget.py b/togglewidget.py new file mode 100644 index 0000000..46c8937 --- /dev/null +++ b/togglewidget.py @@ -0,0 +1,282 @@ +import uuid + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, + QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy +) +from PyQt6.QtCore import Qt, QEvent + +from palette import PALETTE_HEX, PALETTE_VIVID +from styles import TITLE_STYLE +from zonebutton import ZoneButton +from colorswatchpopup import ColorSwatchPopup +from midi_sender import send_cc + +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("") \ No newline at end of file diff --git a/transportwidget.py b/transportwidget.py new file mode 100644 index 0000000..2999adf --- /dev/null +++ b/transportwidget.py @@ -0,0 +1,372 @@ +import uuid + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QPushButton, + QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy +) +from PyQt6.QtCore import Qt, QEvent + +from palette import PALETTE_HEX, PALETTE_VIVID +from styles import TITLE_STYLE +from zonebutton import ZoneButton +from colorswatchpopup import ColorSwatchPopup +from midi_sender import send_transport_cc +from osc_sender import send_osc_message + +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")) \ No newline at end of file