Initial tablet POC: project restructure + WebSocket/HTTP server + JS client
- Restructured project into /app, /server, /client with run.py entry point - WSServer (QWebSocketServer) with log_signal, start/stop, client tracking - HTTP server serves /client on port 8080 - Vanilla JS client: faders, toggles, transport, drag-to-arrange, lock/save layout - Start Tablet button + floating Tablet log window in app UI - Preset broadcast on load/switch, DAW state relay, hardware sync via widget_update - Widget colors synced from app palette, toggle state fixed (sends 0/127 correctly) - Layout saved per preset UUID back to presets.json Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
|
||||
QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy
|
||||
)
|
||||
from PyQt6.QtCore import Qt
|
||||
|
||||
from styles import TITLE_STYLE
|
||||
from midi_sender import send_cc
|
||||
|
||||
|
||||
class PresetWidget(QWidget):
|
||||
def __init__(self, label="Preset", default_cc=1):
|
||||
super().__init__()
|
||||
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
|
||||
self.setFixedWidth(115)
|
||||
|
||||
self.hw_cc = None
|
||||
self.hw_channel = None
|
||||
self.is_learning = False
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
outer.setSpacing(2)
|
||||
|
||||
self.container = QFrame()
|
||||
self.container.setFrameShape(QFrame.Shape.StyledPanel)
|
||||
self.container.setObjectName("presetContainer")
|
||||
self.container.setStyleSheet("""
|
||||
QFrame#presetContainer {
|
||||
background-color: #4a5568;
|
||||
border-radius: 6px;
|
||||
border: 2px solid rgba(0,0,0,0.3);
|
||||
}
|
||||
""")
|
||||
|
||||
inner = QVBoxLayout(self.container)
|
||||
inner.setContentsMargins(4, 6, 4, 6)
|
||||
inner.setSpacing(4)
|
||||
inner.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
|
||||
_grp_style = "QFrame { border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; }"
|
||||
|
||||
# Group 1: Learn + input readout
|
||||
self.grp_learn = QFrame()
|
||||
self.grp_learn.setStyleSheet(_grp_style)
|
||||
_grp1_layout = QVBoxLayout(self.grp_learn)
|
||||
_grp1_layout.setContentsMargins(4, 4, 4, 4)
|
||||
_grp1_layout.setSpacing(3)
|
||||
|
||||
self.learn_btn = QPushButton("Learn")
|
||||
self.learn_btn.setFixedHeight(20)
|
||||
self.learn_btn.setStyleSheet("border: none;")
|
||||
self.learn_btn.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.learn_btn.customContextMenuRequested.connect(self.on_learn_context_menu)
|
||||
self.learn_btn.clicked.connect(self.start_learn)
|
||||
_grp1_layout.addWidget(self.learn_btn)
|
||||
|
||||
self.trigger_mode = "momentary"
|
||||
self.toggle_state = False
|
||||
|
||||
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; border: none;")
|
||||
self.cc_input_lbl.setFixedHeight(16)
|
||||
_grp1_layout.addWidget(self.cc_input_lbl)
|
||||
|
||||
inner.addWidget(self.grp_learn)
|
||||
|
||||
self.arrow_lbl = QLabel("▼")
|
||||
self.arrow_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
self.arrow_lbl.setStyleSheet("color: rgba(255,255,255,0.3); font-size: 9px; background: transparent;")
|
||||
self.arrow_lbl.setFixedHeight(12)
|
||||
inner.addWidget(self.arrow_lbl)
|
||||
|
||||
# Group 2: Mom./Tog. + CC spin + CH spin + output readout
|
||||
self.grp_cc = QFrame()
|
||||
self.grp_cc.setStyleSheet(_grp_style)
|
||||
_grp2_layout = QVBoxLayout(self.grp_cc)
|
||||
_grp2_layout.setContentsMargins(4, 4, 4, 4)
|
||||
_grp2_layout.setSpacing(3)
|
||||
|
||||
# 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)
|
||||
_grp2_layout.addLayout(_trigger_row)
|
||||
|
||||
self.cc_spin = QSpinBox()
|
||||
self.cc_spin.setMinimum(0)
|
||||
self.cc_spin.setMaximum(127)
|
||||
self.cc_spin.setValue(default_cc)
|
||||
self.cc_spin.setFixedWidth(55)
|
||||
_cc_lbl = QLabel("CC")
|
||||
_cc_lbl.setStyleSheet("color: #fff; font-size: 10px; border: none;")
|
||||
self.cc_spin_row = QWidget()
|
||||
_hbox_cc = QHBoxLayout(self.cc_spin_row)
|
||||
_hbox_cc.setContentsMargins(0, 0, 0, 0)
|
||||
_hbox_cc.setSpacing(4)
|
||||
_hbox_cc.addWidget(_cc_lbl)
|
||||
_hbox_cc.addWidget(self.cc_spin)
|
||||
_grp2_layout.addWidget(self.cc_spin_row)
|
||||
|
||||
self.ch_spin = QSpinBox()
|
||||
self.ch_spin.setMinimum(1)
|
||||
self.ch_spin.setMaximum(16)
|
||||
self.ch_spin.setValue(1)
|
||||
self.ch_spin.setFixedWidth(55)
|
||||
_ch_lbl = QLabel("CH")
|
||||
_ch_lbl.setStyleSheet("color: #fff; font-size: 10px; border: none;")
|
||||
self.ch_spin_row = QWidget()
|
||||
_hbox_ch = QHBoxLayout(self.ch_spin_row)
|
||||
_hbox_ch.setContentsMargins(0, 0, 0, 0)
|
||||
_hbox_ch.setSpacing(4)
|
||||
_hbox_ch.addWidget(_ch_lbl)
|
||||
_hbox_ch.addWidget(self.ch_spin)
|
||||
_grp2_layout.addWidget(self.ch_spin_row)
|
||||
|
||||
self.cc_output_lbl = QLabel(f"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; border: none;")
|
||||
self.cc_output_lbl.setFixedHeight(16)
|
||||
_grp2_layout.addWidget(self.cc_output_lbl)
|
||||
|
||||
inner.addWidget(self.grp_cc)
|
||||
|
||||
# Trigger button
|
||||
self.btn = QPushButton(label)
|
||||
self.btn.setFixedHeight(40)
|
||||
self.btn.clicked.connect(self.on_click)
|
||||
inner.addWidget(self.btn)
|
||||
|
||||
# Scribble strip
|
||||
self.title_edit = QLineEdit(label)
|
||||
self.title_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
self.title_edit.setStyleSheet(TITLE_STYLE)
|
||||
self.title_edit.textChanged.connect(self.btn.setText)
|
||||
inner.addWidget(self.title_edit)
|
||||
|
||||
outer.addWidget(self.container)
|
||||
|
||||
def _cc_input_label(self, vel="--"):
|
||||
if self.hw_cc is None:
|
||||
return ""
|
||||
ch = f" CH{self.hw_channel}" if self.hw_channel is not None else ""
|
||||
return f"CC{self.hw_cc}{ch} [{vel}]"
|
||||
|
||||
def _cc_output_label(self, vel="--"):
|
||||
return f"CC{self.cc_spin.value()} CH{self.ch_spin.value()} [{vel}]"
|
||||
|
||||
def start_learn(self):
|
||||
self.is_learning = True
|
||||
self.learn_btn.setText("Listening...")
|
||||
self.learn_btn.setStyleSheet("color: orange; font-weight: bold; border: none;")
|
||||
|
||||
def stop_learn(self):
|
||||
self.is_learning = False
|
||||
self.learn_btn.setStyleSheet("border: none;")
|
||||
if self.hw_cc is not None:
|
||||
self.learn_btn.setText(f"HW: CC{self.hw_cc}")
|
||||
self.cc_input_lbl.setText(self._cc_input_label())
|
||||
self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px; border: none;")
|
||||
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; border: none;")
|
||||
|
||||
def bind_hw_cc(self, cc_number, channel=None):
|
||||
self.hw_cc = cc_number
|
||||
self.hw_channel = channel
|
||||
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.hw_channel = None
|
||||
self.is_learning = False
|
||||
self.learn_btn.setText("Learn")
|
||||
self.learn_btn.setStyleSheet("border: none;")
|
||||
self.cc_input_lbl.setText("")
|
||||
self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px; border: none;")
|
||||
|
||||
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 _fire(self):
|
||||
out_cc = self.cc_spin.value()
|
||||
out_ch = self.ch_spin.value()
|
||||
if self.trigger_mode == "momentary":
|
||||
out_val = 127
|
||||
else:
|
||||
self.toggle_state = not self.toggle_state
|
||||
out_val = 127 if self.toggle_state else 0
|
||||
print(f"[preset] CC{out_cc} CH{out_ch} ({out_val})")
|
||||
send_cc(out_cc, out_val, channel=out_ch)
|
||||
self.cc_output_lbl.setText(self._cc_output_label(out_val))
|
||||
|
||||
def on_click(self):
|
||||
self._fire()
|
||||
|
||||
def on_hw_trigger(self, value):
|
||||
self.cc_input_lbl.setText(self._cc_input_label(value))
|
||||
self._fire()
|
||||
|
||||
def get_state(self):
|
||||
return {
|
||||
"label": self.title_edit.text(),
|
||||
"cc": self.cc_spin.value(),
|
||||
"ch": self.ch_spin.value(),
|
||||
"hw_cc": self.hw_cc,
|
||||
"hw_channel": self.hw_channel,
|
||||
"trigger_mode": self.trigger_mode,
|
||||
}
|
||||
|
||||
def set_state(self, state):
|
||||
self.title_edit.setText(state.get("label", self.title_edit.text()))
|
||||
self.cc_spin.setValue(state.get("cc", 1))
|
||||
self.ch_spin.setValue(state.get("ch", 1))
|
||||
self.hw_cc = state.get("hw_cc", None)
|
||||
self.hw_channel = state.get("hw_channel", None)
|
||||
self.set_trigger_mode(state.get("trigger_mode", "momentary"))
|
||||
if self.hw_cc is not None:
|
||||
self.learn_btn.setText(f"HW: CC{self.hw_cc}")
|
||||
self.cc_input_lbl.setText(self._cc_input_label())
|
||||
self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px; border: none;")
|
||||
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; border: none;")
|
||||
Reference in New Issue
Block a user