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, QTextEdit ) 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 togglewidget import ToggleWidget from faderwidget import FaderWidget from transportwidget import TransportWidget 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, ensure_preset_structure, on_preset_selected, save_preset, save_preset_as, delete_preset, ) 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, ) from osc_receiver import osc_receiver from osc_sender import ( send_osc_message, set_osc_target, ) def set_window(w): global _window _window = w _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 def set_app_window(window: QMainWindow): global app_window app_window = window #MARK: GLOBALS ready = False selected_strip = None learning_strip = None faders = [] strip_width = 100 spacing = 10 padding = 40 loaded_presets = load_presets_file() app_window = None #MARK: CREATE APP app = QApplication(sys.argv) app.setStyle("Fusion") #MARK: MAIN WINDOW containerMain = QWidget() #MARK: PRESET ROW hstack_preset_row = QHBoxLayout() #MARK: PRESENT menuPresets = QComboBox() menuPresets.setMinimumWidth(160) menuPresets.currentTextChanged.connect(lambda name: on_preset_selected(menuPresets, app_window, ready, loaded_presets)) #button preset save buttonSave = QPushButton("Save") buttonSave.setFixedWidth(50) buttonSave.clicked.connect(lambda: save_preset(app_window, menuPresets, loaded_presets)) #button preset save as -> save_as_btn buttonSaveAs = QPushButton("Save As") buttonSaveAs.setFixedWidth(60) buttonSaveAs.clicked.connect(lambda: save_preset_as(app_window, menuPresets, loaded_presets)) #button preset delete -> del_btn buttonRemove = QPushButton("Remove") buttonRemove.setFixedWidth(65) buttonRemove.clicked.connect(lambda: delete_preset(app_window, menuPresets, loaded_presets)) #save status label (should be used for other stuff as well) -> save_status_label labelStatus = QLabel("") labelStatus.setStyleSheet("color: #00e676; font-style: italic;") #container io row hstack_io_row = QHBoxLayout() hstack_io_row.setSpacing(16) # REFACTOR MAP: Old names → New names # self.in_blink → activity_indicator_midi_in # self.in_status → label_connection_midi_in # self.in_combo → picker_midi_in # self.available_in_ports → available_ports_midi_in # vstack_midi_in vstack_midi_in = QVBoxLayout() vstack_midi_in.setSpacing(2) # hstack_midi_in_activity_and_label hstack_midi_in_activity_and_label = QHBoxLayout() # activity_indicator_midi_in activity_indicator_midi_in = QLabel("●") activity_indicator_midi_in.setStyleSheet("color: #444; font-size: 14px; background-color: rgba(255, 0, 0, 0.15); border: 1px solid red; padding: 2px;") # label_connection_midi_in label_connection_midi_in = QLabel("Not Connected") label_connection_midi_in.setStyleSheet("color: #888; background-color: rgba(0, 0, 255, 0.15); border: 1px solid blue; padding: 2px;") # label_midi_in label_midi_in = QLabel("MIDI Input") label_midi_in.setStyleSheet("background-color: rgba(255, 255, 0, 0.15); border: 1px solid yellow; padding: 2px;") # picker_midi_in picker_midi_in = QComboBox() picker_midi_in.setFixedWidth(200) picker_midi_in.setStyleSheet("background-color: rgba(0, 255, 0, 0.15); border: 1px solid green;") # REFACTOR MAP: Old names → New names # self.out_blink → activity_indicator_midi_out # self.out_status → label_connection_midi_out # self.port_combo → picker_midi_out # vstack_midi_out vstack_midi_out = QVBoxLayout() vstack_midi_out.setSpacing(2) # hstack_midi_out_activity_and_label hstack_midi_out_activity_and_label = QHBoxLayout() # activity_indicator_midi_out activity_indicator_midi_out = QLabel("●") activity_indicator_midi_out.setStyleSheet("color: #444; font-size: 14px; background-color: rgba(255, 0, 0, 0.15); border: 1px solid red; padding: 2px;") # label_connection_midi_out label_connection_midi_out = QLabel("Not Connected") label_connection_midi_out.setStyleSheet("color: #888; background-color: rgba(0, 0, 255, 0.15); border: 1px solid blue; padding: 2px;") # label_midi_out label_midi_out = QLabel("MIDI Output") label_midi_out.setStyleSheet("background-color: rgba(255, 255, 0, 0.15); border: 1px solid yellow; padding: 2px;") # picker_midi_out picker_midi_out = QComboBox() picker_midi_out.setFixedWidth(200) picker_midi_out.setStyleSheet("background-color: rgba(0, 255, 0, 0.15); border: 1px solid green;") # REFACTOR MAP: Old names → New names # self.t_out_blink → activity_indicator_transport_out # self.t_out_status → label_connection_transport_out # self.t_out_combo → picker_transport_out # vstack_transport_out vstack_transport_out = QVBoxLayout() vstack_transport_out.setSpacing(2) # hstack_transport_out_activity_and_label hstack_transport_out_activity_and_label = QHBoxLayout() # activity_indicator_transport_out activity_indicator_transport_out = QLabel("●") activity_indicator_transport_out.setStyleSheet("color: #444; font-size: 14px; background-color: rgba(255, 0, 0, 0.15); border: 1px solid red; padding: 2px;") # label_connection_transport_out label_connection_transport_out = QLabel("Not Connected") label_connection_transport_out.setStyleSheet("color: #888; background-color: rgba(0, 0, 255, 0.15); border: 1px solid blue; padding: 2px;") # label_transport_out label_transport_out = QLabel("Transport Output") label_transport_out.setStyleSheet("background-color: rgba(255, 255, 0, 0.15); border: 1px solid yellow; padding: 2px;") # picker_transport_out picker_transport_out = QComboBox() picker_transport_out.setFixedWidth(200) picker_transport_out.setStyleSheet("background-color: rgba(0, 255, 0, 0.15); border: 1px solid green;") # REFACTOR MAP: Old names → New names # self.osc_blink → activity_indicator_osc # self.osc_status → label_connection_osc # self.osc_ip_edit → textbox_osc_daw_ip # self.osc_port_spin → spinner_osc_listen_port # self.osc_send_port_spin → spinner_osc_send_port # self.osc_toggle_btn → button_osc_toggle # self.osc_log_btn → button_osc_messages_log # Layout: vstack_osc vstack_osc = QVBoxLayout() vstack_osc.setSpacing(2) # Layout: hstack_osc_activity_and_label hstack_osc_activity_and_label = QHBoxLayout() # Layout: hstack_osc_ip hstack_osc_ip = QHBoxLayout() # Layout: hstack_osc_ports hstack_osc_ports = QHBoxLayout() # Layout: hstack_osc_buttons hstack_osc_buttons = QHBoxLayout() # Widget: activity_indicator_osc activity_indicator_osc = QLabel("●") activity_indicator_osc.setStyleSheet("color: #444; font-size: 14px; background-color: rgba(255, 0, 0, 0.15); border: 1px solid red; padding: 2px;") # Widget: label_connection_osc label_connection_osc = QLabel("Not Listening") label_connection_osc.setStyleSheet("color: #888; background-color: rgba(0, 0, 255, 0.15); border: 1px solid blue; padding: 2px;") # Widget: textbox_osc_daw_ip textbox_osc_daw_ip = QLineEdit("127.0.0.1") textbox_osc_daw_ip.setFixedWidth(110) textbox_osc_daw_ip.setPlaceholderText("127.0.0.1") # Widget: spinner_osc_listen_port spinner_osc_listen_port = QSpinBox() spinner_osc_listen_port.setMinimum(1024) spinner_osc_listen_port.setMaximum(65535) spinner_osc_listen_port.setValue(9000) spinner_osc_listen_port.setFixedWidth(65) # Widget: spinner_osc_send_port spinner_osc_send_port = QSpinBox() spinner_osc_send_port.setMinimum(1024) spinner_osc_send_port.setMaximum(65535) spinner_osc_send_port.setValue(8000) spinner_osc_send_port.setFixedWidth(65) # Widget: button_osc_toggle button_osc_toggle = QPushButton("Start OSC") button_osc_toggle.setFixedWidth(90) # Widget: button_osc_messages_log button_osc_messages_log = QPushButton("Messages") button_osc_messages_log.setFixedWidth(80) button_osc_messages_log.setCheckable(True) # MARK: OSC STATUS ROW MODULE # REFACTOR MAP: Old names → New names # self.osc_panel → container_osc_feedback # self.osc_play_lbl → label_osc_play_state # self.osc_record_lbl → label_osc_record_state # self.osc_tempo_lbl → label_osc_tempo # self.osc_pos_lbl → label_osc_position # self.osc_track_lbl → label_osc_track # self.osc_preset_lbl → label_osc_preset # self.osc_log_panel → container_osc_in_log # self.osc_log → textedit_osc_in_log # self.osc_out_log_panel → container_osc_out_log # self.osc_out_log → textedit_osc_out_log # Container: container_osc_feedback container_osc_feedback = QWidget() container_osc_feedback.setStyleSheet("background-color: #252526; border-radius: 4px;") # Layout: vstack_osc_feedback vstack_osc_feedback = QVBoxLayout(container_osc_feedback) vstack_osc_feedback.setContentsMargins(8, 6, 8, 6) vstack_osc_feedback.setSpacing(4) # Layout: hstack_osc_transport_state hstack_osc_transport_state = QHBoxLayout() hstack_osc_transport_state.setSpacing(12) # Widget: label_osc_play_state label_osc_play_state = QLabel("■ STOPPED") label_osc_play_state.setStyleSheet("color: #555; font-size: 11px; font-weight: bold;") # Widget: label_osc_record_state label_osc_record_state = QLabel("● REC") label_osc_record_state.setStyleSheet("color: #555; font-size: 11px; font-weight: bold;") # Widget: label_osc_tempo label_osc_tempo = QLabel("BPM: —") label_osc_tempo.setStyleSheet("color: #888; font-size: 11px;") # Widget: label_osc_position label_osc_position = QLabel("Pos: —") label_osc_position.setStyleSheet("color: #888; font-size: 11px;") # Widget: divider_osc_feedback divider_osc_feedback = QWidget() divider_osc_feedback.setFixedHeight(1) divider_osc_feedback.setStyleSheet("background-color: #3a3a3a;") # Layout: hstack_osc_info hstack_osc_info = QHBoxLayout() hstack_osc_info.setSpacing(8) # Widget: label_track_header label_track_header = QLabel("TRACK") label_track_header.setStyleSheet("color: #555; font-size: 9px; font-weight: bold;") # Widget: label_osc_track label_osc_track = QLabel("—") label_osc_track.setStyleSheet("color: #d4d4d4; font-size: 12px; font-weight: bold;") # Widget: label_preset_header label_preset_header = QLabel("PRESET") label_preset_header.setStyleSheet("color: #555; font-size: 9px; font-weight: bold;") # Widget: label_osc_preset label_osc_preset = QLabel("—") label_osc_preset.setStyleSheet("color: #00e676; font-size: 12px; font-weight: bold;") # Widget: spacer_osc_info spacer_osc_info = QWidget() spacer_osc_info.setFixedWidth(16) # Container: container_osc_in_log container_osc_in_log = QWidget(None, Qt.WindowType.Tool) container_osc_in_log.setWindowTitle("OSC In — Incoming") container_osc_in_log.setStyleSheet("background-color: #1a1a1a;") container_osc_in_log.setFixedWidth(360) # Layout: vstack_osc_in_log vstack_osc_in_log = QVBoxLayout(container_osc_in_log) vstack_osc_in_log.setContentsMargins(6, 6, 6, 6) vstack_osc_in_log.setSpacing(4) # Layout: hstack_osc_in_log_header hstack_osc_in_log_header = QHBoxLayout() # Widget: label_osc_in_log_title label_osc_in_log_title = QLabel("OSC In — Incoming from REAPER") label_osc_in_log_title.setStyleSheet("color: #00e676; font-weight: bold; font-size: 11px;") # Widget: button_osc_in_clear button_osc_in_clear = QPushButton("Clear") button_osc_in_clear.setFixedSize(45, 18) # Widget: textedit_osc_in_log textedit_osc_in_log = QTextEdit() textedit_osc_in_log.setReadOnly(True) textedit_osc_in_log.setStyleSheet(""" QTextEdit { background-color: #111111; color: #00e676; font-family: monospace; font-size: 10px; border: none; } """) # Container: container_osc_out_log container_osc_out_log = QWidget(None, Qt.WindowType.Tool) container_osc_out_log.setWindowTitle("OSC Out — Outgoing") container_osc_out_log.setStyleSheet("background-color: #1a1a1a;") container_osc_out_log.setFixedWidth(360) # Layout: vstack_osc_out_log vstack_osc_out_log = QVBoxLayout(container_osc_out_log) vstack_osc_out_log.setContentsMargins(6, 6, 6, 6) vstack_osc_out_log.setSpacing(4) # Layout: hstack_osc_out_log_header hstack_osc_out_log_header = QHBoxLayout() # Widget: label_osc_out_log_title label_osc_out_log_title = QLabel("OSC Out — Sent to REAPER") label_osc_out_log_title.setStyleSheet("color: #1e88e5; font-weight: bold; font-size: 11px;") # Widget: button_osc_out_clear button_osc_out_clear = QPushButton("Clear") button_osc_out_clear.setFixedSize(45, 18) # Widget: textedit_osc_out_log textedit_osc_out_log = QTextEdit() textedit_osc_out_log.setReadOnly(True) textedit_osc_out_log.setStyleSheet(""" QTextEdit { background-color: #111111; color: #1e88e5; font-family: monospace; font-size: 10px; border: none; } """) # MARK: PRESET BROWSER ROW MODULE # REFACTOR MAP: Old names → New names # self.browser_back_spin → spinner_preset_browser_back # self.browser_fwd_spin → spinner_preset_browser_fwd # Widget: label_preset_browser_title label_preset_browser_title = QLabel("Preset Browser") # Layout: hstack_preset_browser hstack_preset_browser = QHBoxLayout() hstack_preset_browser.setSpacing(8) # Widget: button_preset_browser_back button_preset_browser_back = QPushButton("◀") button_preset_browser_back.setFixedSize(36, 28) # Widget: spinner_preset_browser_back spinner_preset_browser_back = QSpinBox() spinner_preset_browser_back.setMinimum(1) spinner_preset_browser_back.setMaximum(127) spinner_preset_browser_back.setValue(22) spinner_preset_browser_back.setFixedWidth(55) # Widget: button_preset_browser_fwd button_preset_browser_fwd = QPushButton("▶") button_preset_browser_fwd.setFixedSize(36, 28) # Widget: spinner_preset_browser_fwd spinner_preset_browser_fwd = QSpinBox() spinner_preset_browser_fwd.setMinimum(1) spinner_preset_browser_fwd.setMaximum(127) spinner_preset_browser_fwd.setValue(23) spinner_preset_browser_fwd.setFixedWidth(55) # Widget: spacer_preset_browser spacer_preset_browser = QWidget() spacer_preset_browser.setFixedWidth(12) # MARK: TRANSPORT MODULE # REFACTOR MAP: Old names → New names # self.full_transport_row → hstack_full_transport # self.transport_zone_left_slot → hstack_transport_zone_left # self.transport_vsep_left → vsep_transport_left # self.transport_row → hstack_transport_center # self.transport_vsep_right → vsep_transport_right # self.transport_zone_right_slot → hstack_transport_zone_right # Layout: hstack_full_transport hstack_full_transport = QHBoxLayout() hstack_full_transport.setSpacing(spacing) # Layout: hstack_transport_zone_left hstack_transport_zone_left = QHBoxLayout() hstack_transport_zone_left.setSpacing(0) # Widget: vsep_transport_left vsep_transport_left = QFrame() vsep_transport_left.setFrameShape(QFrame.Shape.VLine) vsep_transport_left.setFixedWidth(1) vsep_transport_left.setStyleSheet("background-color: #3a3a3a; border: none;") vsep_transport_left.setVisible(False) # Layout: hstack_transport_center hstack_transport_center = QHBoxLayout() hstack_transport_center.setSpacing(spacing) # Widget: vsep_transport_right vsep_transport_right = QFrame() vsep_transport_right.setFrameShape(QFrame.Shape.VLine) vsep_transport_right.setFixedWidth(1) vsep_transport_right.setStyleSheet("background-color: #3a3a3a; border: none;") vsep_transport_right.setVisible(False) # Layout: hstack_transport_zone_right hstack_transport_zone_right = QHBoxLayout() hstack_transport_zone_right.setSpacing(0) # MARK: SELECTED CHANNEL ROW MODULE # REFACTOR MAP: Old names → New names # self.selected_label → label_selected_channel # Layout: hstack_selected_channel_and_save_status hstack_selected_channel_and_save_status = QHBoxLayout() # Widget: label_selected_channel label_selected_channel = QLabel("Selected: None") label_selected_channel.setStyleSheet("color: #00ff88; font-weight: bold;") label_selected_channel.setAlignment(Qt.AlignmentFlag.AlignCenter) # MARK: FADERS ROW MODULE # REFACTOR MAP: Old names → New names # self.full_fader_row → hstack_full_fader # self.zone_left_slot → hstack_fader_zone_left # self.vsep_left → separator_vertical_fader_zone_left # self.fader_row → hstack_fader_center # self.vsep_right → separator_vertical_fader_zone_right # self.zone_right_slot → hstack_fader_zone_right # Layout: hstack_full_fader hstack_full_fader = QHBoxLayout() hstack_full_fader.setSpacing(spacing) # Layout: hstack_fader_zone_left hstack_fader_zone_left = QHBoxLayout() hstack_fader_zone_left.setSpacing(0) # Widget: separator_vertical_fader_zone_left separator_vertical_fader_zone_left = QFrame() separator_vertical_fader_zone_left.setFrameShape(QFrame.Shape.VLine) separator_vertical_fader_zone_left.setFixedWidth(1) separator_vertical_fader_zone_left.setStyleSheet("background-color: #3a3a3a; border: none;") separator_vertical_fader_zone_left.setVisible(False) # Layout: hstack_fader_center hstack_fader_center = QHBoxLayout() hstack_fader_center.setSpacing(spacing) # Widget: separator_vertical_fader_zone_right separator_vertical_fader_zone_right = QFrame() separator_vertical_fader_zone_right.setFrameShape(QFrame.Shape.VLine) separator_vertical_fader_zone_right.setFixedWidth(1) separator_vertical_fader_zone_right.setStyleSheet("background-color: #3a3a3a; border: none;") separator_vertical_fader_zone_right.setVisible(False) # Layout: hstack_fader_zone_right hstack_fader_zone_right = QHBoxLayout() hstack_fader_zone_right.setSpacing(0) # MARK: TOGGLE ROW MODULE # REFACTOR MAP: Old names → New names # self.full_toggle_row → hstack_full_toggle # self.toggle_zone_left_slot → hstack_toggle_zone_left # self.toggle_vsep_left → separator_vertical_toggle_zone_left # self.toggle_row → hstack_toggle_center # self.toggle_vsep_right → separator_vertical_toggle_zone_right # self.toggle_zone_right_slot → hstack_toggle_zone_right # Layout: hstack_full_toggle hstack_full_toggle = QHBoxLayout() hstack_full_toggle.setSpacing(spacing) # Layout: hstack_toggle_zone_left hstack_toggle_zone_left = QHBoxLayout() hstack_toggle_zone_left.setSpacing(0) # Widget: separator_vertical_toggle_zone_left separator_vertical_toggle_zone_left = QFrame() separator_vertical_toggle_zone_left.setFrameShape(QFrame.Shape.VLine) separator_vertical_toggle_zone_left.setFixedWidth(1) separator_vertical_toggle_zone_left.setStyleSheet("background-color: #3a3a3a; border: none;") separator_vertical_toggle_zone_left.setVisible(False) # Layout: hstack_toggle_center hstack_toggle_center = QHBoxLayout() hstack_toggle_center.setSpacing(10) # Widget: separator_vertical_toggle_zone_right separator_vertical_toggle_zone_right = QFrame() separator_vertical_toggle_zone_right.setFrameShape(QFrame.Shape.VLine) separator_vertical_toggle_zone_right.setFixedWidth(1) separator_vertical_toggle_zone_right.setStyleSheet("background-color: #3a3a3a; border: none;") separator_vertical_toggle_zone_right.setVisible(False) # Layout: hstack_toggle_zone_right hstack_toggle_zone_right = QHBoxLayout() hstack_toggle_zone_right.setSpacing(0) #MARK: MAIN WINDOW START class MainWindow(QMainWindow): def __init__(self): super().__init__() global loaded_presets global containerPresetRow global menuPresets global buttonSave global buttonSaveAs global buttonRemove global labelStatus global hstack_io_row global available_ports_midi_in global picker_midi_in global label_midi_in global label_connection_midi_in global activity_indicator_midi_in global hstack_midi_in_activity_and_label global vstack_midi_in self.setWindowTitle("Controller") #set app window reference set_app_window(self) # ui main window self.setCentralWidget(containerMain) main_layout = QVBoxLayout(containerMain) #MARK: PRESETS ROW # ui preset row + drop down + buttont main_layout.addLayout(hstack_preset_row) hstack_preset_row.addWidget(menuPresets) #dropdown hstack_preset_row.addWidget(buttonSave) #save button hstack_preset_row.addWidget(buttonSaveAs) #save as button hstack_preset_row.addWidget(buttonRemove) #remove button hstack_preset_row.addStretch() #section seperator main_layout.addWidget(self._separator()) #MARK: MIDI IN COLUMN 1 #build the hstack with activity indicator and connection state label hstack_midi_in_activity_and_label.addWidget(activity_indicator_midi_in) hstack_midi_in_activity_and_label.addWidget(label_connection_midi_in) hstack_midi_in_activity_and_label.addStretch() #add all 3 to the vstack vstack_midi_in.addLayout(hstack_midi_in_activity_and_label) #activity indicator and connection label vstack_midi_in.addWidget(label_midi_in) # midi input label vstack_midi_in.addWidget(picker_midi_in) # dropdown menu # add the combined stack to the row hstack_io_row.addLayout(vstack_midi_in) # picker_midi_in population & signal available_ports_midi_in = get_available_input_ports() for display_name, idx, available in available_ports_midi_in: picker_midi_in.addItem(display_name) if not available: item_index = picker_midi_in.count() - 1 picker_midi_in.model().item(item_index).setEnabled(False) picker_midi_in.model().item(item_index).setForeground(QColor('#888888')) picker_midi_in.currentTextChanged.connect(self.on_input_port_select) #MARK: MIDI OUT COLUMN 2 # Globals for mutation safety global vstack_midi_out global hstack_midi_out_activity_and_label global activity_indicator_midi_out global label_connection_midi_out global label_midi_out global picker_midi_out # build the hstack with activity indicator and connection state label hstack_midi_out_activity_and_label.addWidget(activity_indicator_midi_out) hstack_midi_out_activity_and_label.addWidget(label_connection_midi_out) hstack_midi_out_activity_and_label.addStretch() # add all 3 to the vstack vstack_midi_out.addLayout(hstack_midi_out_activity_and_label) # activity indicator and connection label vstack_midi_out.addWidget(label_midi_out) # midi output label vstack_midi_out.addWidget(picker_midi_out) # port dropdown # populate the dropdown picker_midi_out.addItems(available_ports) picker_midi_out.currentTextChanged.connect(self.on_port_select) # add the combined stack to the row hstack_io_row.addLayout(vstack_midi_out) #MARK: TRANSPORT OUT COLUMN 3 # Globals for mutation safety global vstack_transport_out global hstack_transport_out_activity_and_label global activity_indicator_transport_out global label_connection_transport_out global label_transport_out global picker_transport_out # build the hstack with activity indicator and connection state label hstack_transport_out_activity_and_label.addWidget(activity_indicator_transport_out) hstack_transport_out_activity_and_label.addWidget(label_connection_transport_out) hstack_transport_out_activity_and_label.addStretch() # add all 3 to the vstack vstack_transport_out.addLayout(hstack_transport_out_activity_and_label) # activity indicator and connection label vstack_transport_out.addWidget(label_transport_out) # transport output label vstack_transport_out.addWidget(picker_transport_out) # port dropdown # populate the dropdown picker_transport_out.addItems(available_ports) picker_transport_out.currentTextChanged.connect(self.on_transport_output_select) # add the combined stack to the row hstack_io_row.addLayout(vstack_transport_out) #MARK: OSC COLUMN 4 # Globals for mutation safety global vstack_osc global hstack_osc_activity_and_label global activity_indicator_osc global label_connection_osc global hstack_osc_ip global textbox_osc_daw_ip global hstack_osc_ports global spinner_osc_listen_port global spinner_osc_send_port global hstack_osc_buttons global button_osc_toggle global button_osc_messages_log # build hstack with activity indicator and connection label hstack_osc_activity_and_label.addWidget(activity_indicator_osc) hstack_osc_activity_and_label.addWidget(label_connection_osc) hstack_osc_activity_and_label.addStretch() # build hstack for DAW IP configuration hstack_osc_ip.addWidget(QLabel("DAW IP")) hstack_osc_ip.addWidget(textbox_osc_daw_ip) hstack_osc_ip.addStretch() # build hstack for listen/send ports hstack_osc_ports.addWidget(QLabel("Listen")) hstack_osc_ports.addWidget(spinner_osc_listen_port) hstack_osc_ports.addSpacing(6) hstack_osc_ports.addWidget(QLabel("Send")) hstack_osc_ports.addWidget(spinner_osc_send_port) hstack_osc_ports.addStretch() # build hstack for control buttons hstack_osc_buttons.addWidget(button_osc_toggle) hstack_osc_buttons.addWidget(button_osc_messages_log) hstack_osc_buttons.addStretch() # add all rows to vstack vstack_osc.addLayout(hstack_osc_activity_and_label) # activity indicator and connection label vstack_osc.addLayout(hstack_osc_ip) # DAW IP input vstack_osc.addLayout(hstack_osc_ports) # listen and send ports vstack_osc.addLayout(hstack_osc_buttons) # control buttons # connect signals textbox_osc_daw_ip.textChanged.connect(self.on_osc_ip_changed) spinner_osc_send_port.valueChanged.connect(self.on_osc_send_port_changed) button_osc_toggle.clicked.connect(self.toggle_osc) button_osc_messages_log.clicked.connect(self.toggle_osc_log) # add the combined stack to the row hstack_io_row.addLayout(vstack_osc) # add the combined stack to the row hstack_io_row.addLayout(vstack_osc) hstack_io_row.addStretch() main_layout.addLayout(hstack_io_row) main_layout.addWidget(self._separator()) # MARK: OSC STATUS ROW global container_osc_feedback global vstack_osc_feedback global hstack_osc_transport_state global label_osc_play_state global label_osc_record_state global label_osc_tempo global label_osc_position global divider_osc_feedback global hstack_osc_info global label_track_header global label_osc_track global label_preset_header global label_osc_preset global spacer_osc_info global container_osc_in_log global vstack_osc_in_log global hstack_osc_in_log_header global label_osc_in_log_title global button_osc_in_clear global textedit_osc_in_log global container_osc_out_log global vstack_osc_out_log global hstack_osc_out_log_header global label_osc_out_log_title global button_osc_out_clear global textedit_osc_out_log #MARK: OSC FEEDBACK CONTAINER # assemble transport state row hstack_osc_transport_state.addWidget(label_osc_play_state) hstack_osc_transport_state.addWidget(label_osc_record_state) hstack_osc_transport_state.addStretch() hstack_osc_transport_state.addWidget(label_osc_tempo) hstack_osc_transport_state.addWidget(label_osc_position) # assemble track and preset info row hstack_osc_info.addWidget(label_track_header) hstack_osc_info.addWidget(label_osc_track) hstack_osc_info.addWidget(spacer_osc_info) hstack_osc_info.addWidget(label_preset_header) hstack_osc_info.addWidget(label_osc_preset) hstack_osc_info.addStretch() # assemble main feedback container vstack_osc_feedback.addLayout(hstack_osc_transport_state) # play, record, tempo, position vstack_osc_feedback.addWidget(divider_osc_feedback) # divider line vstack_osc_feedback.addLayout(hstack_osc_info) # track and preset labels container_osc_feedback.setVisible(False) main_layout.addWidget(container_osc_feedback) #MARK: OSC IN LOG CONTAINER # assemble header hstack_osc_in_log_header.addWidget(label_osc_in_log_title) hstack_osc_in_log_header.addStretch() hstack_osc_in_log_header.addWidget(button_osc_in_clear) # assemble container vstack_osc_in_log.addLayout(hstack_osc_in_log_header) vstack_osc_in_log.addWidget(textedit_osc_in_log) # connect signal button_osc_in_clear.clicked.connect(lambda: textedit_osc_in_log.clear()) container_osc_in_log.setVisible(False) #MARK: OSC OUT LOG CONTAINER # assemble header hstack_osc_out_log_header.addWidget(label_osc_out_log_title) hstack_osc_out_log_header.addStretch() hstack_osc_out_log_header.addWidget(button_osc_out_clear) # assemble container vstack_osc_out_log.addLayout(hstack_osc_out_log_header) vstack_osc_out_log.addWidget(textedit_osc_out_log) # connect signal button_osc_out_clear.clicked.connect(lambda: textedit_osc_out_log.clear()) container_osc_out_log.setVisible(False) #MARK: PRESET BROWSER # Globals for mutation safety global label_preset_browser_title global hstack_preset_browser global button_preset_browser_back global spinner_preset_browser_back global button_preset_browser_fwd global spinner_preset_browser_fwd global spacer_preset_browser # assemble browser controls hstack_preset_browser.addWidget(button_preset_browser_back) hstack_preset_browser.addWidget(spinner_preset_browser_back) hstack_preset_browser.addWidget(spacer_preset_browser) hstack_preset_browser.addWidget(button_preset_browser_fwd) hstack_preset_browser.addWidget(spinner_preset_browser_fwd) hstack_preset_browser.addStretch() # add to main layout main_layout.addWidget(label_preset_browser_title) main_layout.addLayout(hstack_preset_browser) # connect signals button_preset_browser_back.clicked.connect(lambda: send_cc(spinner_preset_browser_back.value(), 127)) button_preset_browser_fwd.clicked.connect(lambda: send_cc(spinner_preset_browser_fwd.value(), 127)) main_layout.addWidget(self._separator()) #MARK: TRANSPORT ROW # Instance state self.transports = [] self.transport_strip_width = 100 self._capturing_key_for = None # Globals for mutation safety global hstack_full_transport global hstack_transport_zone_left global vsep_transport_left global hstack_transport_center global vsep_transport_right global hstack_transport_zone_right #MARK: TRANSPORT ROW # assemble full transport row hstack_full_transport.addLayout(hstack_transport_zone_left) # left zone slot hstack_full_transport.addWidget(vsep_transport_left) # left separator hstack_full_transport.addLayout(hstack_transport_center) # center controls hstack_full_transport.addWidget(vsep_transport_right) # right separator hstack_full_transport.addLayout(hstack_transport_zone_right) # right zone slot # add to main layout main_layout.addLayout(hstack_full_transport) # MARK: SELECTED CHANNEL ROW global hstack_selected_channel_and_save_status global label_selected_channel global labelStatus #MARK: SELECTED CHANNEL AND SAVE STATUS ROW # assemble info row hstack_selected_channel_and_save_status.addStretch() hstack_selected_channel_and_save_status.addWidget(label_selected_channel) hstack_selected_channel_and_save_status.addStretch() labelStatus.setParent(None) # reparent status label hstack_selected_channel_and_save_status.addWidget(labelStatus) # add to main layout main_layout.addLayout(hstack_selected_channel_and_save_status) #MARK: FADERS ROW global hstack_full_fader global hstack_fader_zone_left global separator_vertical_fader_zone_left global hstack_fader_center global separator_vertical_fader_zone_right global hstack_fader_zone_right # assemble full fader row hstack_full_fader.addLayout(hstack_fader_zone_left) # left zone slot hstack_full_fader.addWidget(separator_vertical_fader_zone_left) # left separator hstack_full_fader.addLayout(hstack_fader_center) # center faders hstack_full_fader.addWidget(separator_vertical_fader_zone_right) # right separator hstack_full_fader.addLayout(hstack_fader_zone_right) # right zone slot # add to main layout main_layout.addLayout(hstack_full_fader) main_layout.addWidget(self._separator()) #MARK: TOGGLE ROW self.toggles = [] self.toggle_strip_width = 100 # Globals for mutation safety global hstack_full_toggle global hstack_toggle_zone_left global separator_vertical_toggle_zone_left global hstack_toggle_center global separator_vertical_toggle_zone_right global hstack_toggle_zone_right #MARK: TOGGLES ROW # assemble full toggle row hstack_full_toggle.addLayout(hstack_toggle_zone_left) # left zone slot hstack_full_toggle.addWidget(separator_vertical_toggle_zone_left) # left separator hstack_full_toggle.addLayout(hstack_toggle_center) # center toggles hstack_full_toggle.addWidget(separator_vertical_toggle_zone_right) # right separator hstack_full_toggle.addLayout(hstack_toggle_zone_right) # right zone slot # add to main layout main_layout.addLayout(hstack_full_toggle) main_layout.addStretch() #MARK: SET DEFAULTS / LOAD PRESETS # Add initial 2 faders, 1 toggle, 4 transport strips loaded_presets = ensure_preset_structure(loaded_presets) 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) global ready 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(activity_indicator_midi_in)) midi_receiver.midi_out_signal.connect(lambda: self.blink(activity_indicator_midi_out)) midi_receiver.transport_out_signal.connect(lambda: self.blink(activity_indicator_transport_out)) 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 global faders center = [f for f in 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 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 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 [hstack_toggle_center, hstack_toggle_zone_left, hstack_toggle_zone_right]: 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 = hstack_toggle_zone_left.count() - 1 hstack_toggle_zone_left.addWidget(toggle) separator_vertical_toggle_zone_left.setVisible(True) elif toggle.zone == "right": toggle.zone_index = hstack_toggle_zone_right.count() - 1 hstack_toggle_zone_right.addWidget(toggle) separator_vertical_toggle_zone_right.setVisible(True) else: idx = toggle.center_index if toggle.center_index is not None else hstack_toggle_center.count() idx = min(idx, hstack_toggle_center.count()) hstack_toggle_center.insertWidget(idx, toggle) toggle.center_index = None toggle.zone_index = None separator_vertical_toggle_zone_left.setVisible(any(t.zone == "left" for t in self.toggles)) separator_vertical_toggle_zone_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 global faders if fader.zone is not None and fader.center_index is None: # Being zoned for first time — save current center position if fader in faders: fader.center_index = faders.index(fader) # Remove by UUID from whichever slot it's in for layout in [hstack_fader_center, hstack_fader_zone_left, hstack_fader_zone_right]: 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 = hstack_fader_zone_left.count() - 1 # subtract placeholder hstack_fader_zone_left.addWidget(fader) separator_vertical_fader_zone_left.setVisible(True) elif fader.zone == "right": fader.zone_index = hstack_fader_zone_right.count() - 1 hstack_fader_zone_right.addWidget(fader) separator_vertical_fader_zone_right.setVisible(True) else: idx = fader.center_index if fader.center_index is not None else hstack_fader_center.count() idx = min(idx, hstack_fader_center.count()) hstack_fader_center.insertWidget(idx, fader) fader.center_index = None fader.zone_index = None separator_vertical_fader_zone_left.setVisible(any(f.zone == "left" for f in faders)) separator_vertical_fader_zone_right.setVisible(any(f.zone == "right" for f in faders)) has_left = any(f.zone == "left" for f in faders) has_right = any(f.zone == "right" for f in faders) self.update_window_width() self.refresh_all_id_labels() def select_strip(self, strip): global selected_strip if selected_strip and selected_strip != strip: selected_strip.set_selected(False) selected_strip = strip strip.set_selected(True) label_selected_channel.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) hstack_toggle_center.insertWidget(idx + 1, toggle) else: self.toggles.append(toggle) hstack_toggle_center.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): global selected_strip if len(self.toggles) <= 1: return if selected_strip == toggle: selected_strip = None label_selected_channel.setText("Selected: None") hstack_toggle_center.removeWidget(toggle) toggle.deleteLater() self.toggles.remove(toggle) self.update_window_width() self.refresh_all_id_labels() def add_fader(self, insert_after=None): global faders n = len(faders) + 1 fader = FaderWidget(f"Fader {n}", default_cc=1) if insert_after is not None and insert_after in faders: idx = faders.index(insert_after) faders.insert(idx + 1, fader) hstack_fader_center.insertWidget(idx + 1, fader) else: faders.append(fader) hstack_fader_center.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): global selected_strip global faders global spacing if len(faders) <= 1: return if selected_strip == fader: selected_strip = None label_selected_channel.setText("Selected: None") hstack_fader_center.removeWidget(fader) fader.deleteLater() faders.remove(fader) self.update_window_width() self.refresh_all_id_labels() def update_window_width(self): global faders global strip_width nf = len(faders) nt = len(self.toggles) fader_w = (nf * strip_width) + ((nf - 1) * spacing) + padding toggle_w = (nt * self.toggle_strip_width) + ((nt - 1) * spacing) + 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): global loaded_presets global menuPresets menuPresets.blockSignals(True) menuPresets.clear() menuPresets.addItems(sorted(loaded_presets["presets"].keys())) menuPresets.blockSignals(False) def restore_last_used(self): global loaded_presets global menuPresets last = loaded_presets.get("__last_used__") if last and last in loaded_presets["presets"]: menuPresets.blockSignals(True) menuPresets.setCurrentText(last) menuPresets.blockSignals(False) self.apply_preset(loaded_presets["presets"][last]) print(f"Restored last used preset: {last}") def _show_save_status(self): global labelStatus 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}" labelStatus.setText(f"[ Saved {time_str} ]") QTimer.singleShot(4000, lambda: labelStatus.setText("")) def _build_preset_data(self, name): global faders return { "midi_port": picker_midi_out.currentText(), "midi_input": picker_midi_in.currentText(), "transport_port": picker_transport_out.currentText(), "faders": [f.get_state() for f in faders], "toggles": [t.get_state() for t in self.toggles], "transports": [t.get_state() for t in self.transports], "browser": { "back_cc": spinner_preset_browser_back.value(), "fwd_cc": spinner_preset_browser_fwd.value() }, "osc": { "ip": textbox_osc_daw_ip.text(), "listen_port": spinner_osc_listen_port.value(), "send_port": spinner_osc_send_port.value(), } } def apply_preset(self, preset): global faders global available_ports_midi_in port_name = preset.get("midi_port", "") if port_name in available_ports: picker_midi_out.setCurrentText(port_name) transport_port = preset.get("transport_port", "") if transport_port in available_ports: picker_transport_out.setCurrentText(transport_port) midi_input = preset.get("midi_input", "") input_names = [entry[0] for entry in available_ports_midi_in] if midi_input in input_names: picker_midi_in.setCurrentText(midi_input) fader_states = preset.get("faders", []) while len(faders) < len(fader_states): self.add_fader() while len(faders) > len(fader_states): widget = faders.pop() hstack_fader_center.removeWidget(widget) widget.deleteLater() self.update_window_width() for i, widget in enumerate(faders): widget.set_state(fader_states[i]) # First place all center faders in their saved positions center_faders = sorted( [f for f in 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: hstack_fader_center.removeWidget(f) f.setParent(None) for f in center_faders: hstack_fader_center.addWidget(f) # Then place zoned faders in zone order left_faders = sorted( [f for f in 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 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: hstack_fader_center.removeWidget(f) f.setParent(None) hstack_fader_zone_left.addWidget(f) for f in right_faders: hstack_fader_center.removeWidget(f) f.setParent(None) hstack_fader_zone_right.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() hstack_toggle_center.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: hstack_toggle_center.removeWidget(t) t.setParent(None) for t in center_toggles: hstack_toggle_center.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: hstack_toggle_center.removeWidget(t) t.setParent(None) hstack_toggle_zone_left.addWidget(t) for t in right_toggles: hstack_toggle_center.removeWidget(t) t.setParent(None) hstack_toggle_zone_right.addWidget(t) browser = preset.get("browser", {}) spinner_preset_browser_back.setValue(browser.get("back_cc", 22)) spinner_preset_browser_fwd.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: textbox_osc_daw_ip.setText(osc.get("ip", "127.0.0.1")) spinner_osc_listen_port.setValue(osc.get("listen_port", 9000)) spinner_osc_send_port.setValue(osc.get("send_port", 8000)) def on_input_port_select(self, display_name): global available_ports_midi_in if not display_name: return port_entry = next((entry for entry in available_ports_midi_in if entry[0] == display_name), None) if port_entry is None: return display_name, original_index, available = port_entry if not available: label_connection_midi_in.setText("In use by another program") label_connection_midi_in.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) label_connection_midi_in.setText("Connected") label_connection_midi_in.setStyleSheet("color: #27ae60;") print(f"Opened input port: {display_name}") except Exception as e: print(f"Failed to open port {display_name}: {e}") label_connection_midi_in.setText("Failed to open") label_connection_midi_in.setStyleSheet("color: #c0392b;") def on_hw_cc(self, fader_uid, value): global faders for fader in 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): global learning_strip if learning_strip and learning_strip != fader: learning_strip.stop_learn() learning_strip = fader fader.start_learn() def on_midi_in(self, event, data=None): global learning_strip global faders 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 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 learning_strip: learning_strip.bind_hw_cc(cc_num) 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 = picker_midi_out.currentText() t_port = picker_transport_out.currentText() # Output combo — gray out transport port for i in range(picker_midi_out.model().rowCount()): item = picker_midi_out.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(picker_transport_out.model().rowCount()): item = picker_transport_out.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) label_connection_midi_out.setText("Connected") label_connection_midi_out.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) label_connection_transport_out.setText("Connected") label_connection_transport_out.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) hstack_transport_center.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 [hstack_transport_center, hstack_transport_zone_left, hstack_transport_zone_right]: 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 = hstack_transport_zone_left.count() hstack_transport_zone_left.addWidget(transport) vsep_transport_left.setVisible(True) elif transport.zone == "right": transport.zone_index = hstack_transport_zone_right.count() hstack_transport_zone_right.addWidget(transport) vsep_transport_right.setVisible(True) else: idx = transport.center_index if transport.center_index is not None else hstack_transport_center.count() hstack_transport_center.insertWidget(min(idx, hstack_transport_center.count()), transport) transport.center_index = None transport.zone_index = None vsep_transport_left.setVisible(any(t.zone == "left" for t in self.transports)) vsep_transport_right.setVisible(any(t.zone == "right" for t in self.transports)) self.refresh_all_id_labels() def toggle_osc_log(self, checked): container_osc_in_log.setVisible(checked) container_osc_out_log.setVisible(checked) self._position_osc_log() def _position_osc_log(self): geo = self.frameGeometry() h = geo.height() # In panel to the right container_osc_in_log.setFixedHeight(h) container_osc_in_log.move(geo.right() + 4, geo.top()) # Out panel stacked below in panel container_osc_out_log.setFixedWidth(360) container_osc_out_log.setFixedHeight(h // 2) container_osc_out_log.move(geo.right() + 4, geo.top() + h // 2 + 4) # Adjust in panel height container_osc_in_log.setFixedHeight(h // 2 - 4) def resizeEvent(self, event): super().resizeEvent(event) if container_osc_in_log.isVisible(): self._position_osc_log() def moveEvent(self, event): super().moveEvent(event) if container_osc_in_log.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}" textedit_osc_in_log.append(line) doc = textedit_osc_in_log.document() if doc.blockCount() > 200: cursor = textedit_osc_in_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}" textedit_osc_out_log.append(line) doc = textedit_osc_out_log.document() if doc.blockCount() > 200: cursor = textedit_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): set_osc_target(ip=ip.strip() or "127.0.0.1") def on_osc_send_port_changed(self, port): set_osc_target(port=port) def toggle_osc(self): if _osc_server is not None: stop_osc_server() label_connection_osc.setText("Not Listening") label_connection_osc.setStyleSheet("color: #888;") button_osc_toggle.setText("Start OSC") activity_indicator_osc.setStyleSheet("color: #444; font-size: 14px;") container_osc_feedback.setVisible(False) else: port = spinner_osc_listen_port.value() ok = start_osc_server(port) if ok: label_connection_osc.setText(f"Listening :{port}") label_connection_osc.setStyleSheet("color: #27ae60;") button_osc_toggle.setText("Stop OSC") activity_indicator_osc.setStyleSheet("color: #2ecc71; font-size: 14px;") container_osc_feedback.setVisible(True) else: label_connection_osc.setText("Failed") label_connection_osc.setStyleSheet("color: #c0392b;") def on_osc_track(self, name): label_osc_track.setText(name) self.blink(activity_indicator_osc) def on_osc_preset(self, name): label_osc_preset.setText(name) self.blink(activity_indicator_osc) def on_osc_tempo(self, bpm): label_osc_tempo.setText(f"BPM: {bpm:.1f}") def on_osc_position(self, pos): label_osc_position.setText(f"Pos: {pos}") def on_osc_play(self, state): if state: label_osc_play_state.setText("▶ PLAYING") label_osc_play_state.setStyleSheet("color: #00e676; font-size: 11px; font-weight: bold;") else: label_osc_play_state.setText("■ STOPPED") label_osc_play_state.setStyleSheet("color: #555; font-size: 11px; font-weight: bold;") def on_osc_record(self, state): if state: label_osc_record_state.setText("● REC") label_osc_record_state.setStyleSheet("color: #e53935; font-size: 11px; font-weight: bold;") else: label_osc_record_state.setText("● REC") label_osc_record_state.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) # 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())