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