Files
virtual-controller/main.py
T
Paul Lipscomb 3e2147fa02 initial commit
2026-06-16 22:37:38 -04:00

2473 lines
100 KiB
Python

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