Restructure app-desktop as self-contained package, add PyInstaller build
Consolidates everything the desktop app depends on into app-desktop/ so it packages cleanly as a standalone Windows exe: - app-desktop-macos/ -> app-desktop/src/ (drop macOS-only naming, app now targets Windows via PyInstaller too) - remote-server/ws_server.py and app-presets/ moved into app-desktop/src/ (both were exclusive dependencies of main.py/presets.py) — fixes a PyInstaller "missing module" error for ws_server that only surfaced once packaging was attempted, since its old location wasn't on the analyzer's search path - .venv relocated into app-desktop/src/ alongside the code it serves - run.py moved into src/, path logic simplified now that it's co-located with main.py instead of bridging from the repo root - Deleted app/, server/ — stale __pycache__-only fossils from prior reorgs - .gitignore: added missing .venv/ entry (was only covering venv/, a different name — the real folder was never actually ignored) and app-desktop/build|dist/ for PyInstaller output presets.py: added a frozen-vs-source branch. Running from source is unchanged (app-presets/ next to the code). A packaged exe can't write to Program Files, so it uses %APPDATA%\VirtualController\ instead — standard Windows convention. First launch on a machine with no AppData presets yet seeds from presets.json bundled into the exe (PyInstaller --add-data, baked into VirtualController.spec), so a fresh install starts with real presets instead of empty; every launch after that only touches AppData. Verified: exe builds clean (no missing-module warnings), launches with a real window, and a clean first-launch correctly seeds AppData from the bundled presets. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,663 @@
|
||||
import uuid
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QSlider,
|
||||
QLabel, QLineEdit, QFrame, QSizePolicy,
|
||||
QPushButton, QCheckBox, QSpinBox, QComboBox
|
||||
)
|
||||
from PyQt6.QtCore import Qt, QEvent, QTimer
|
||||
|
||||
from palette import (
|
||||
PALETTE_NAMES,
|
||||
PALETTE_HEX,
|
||||
PALETTE_VIVID,
|
||||
lighten_hex,
|
||||
darken_hex,
|
||||
)
|
||||
from styles import FADER_STYLE, TITLE_STYLE
|
||||
from zonebutton import ZoneButton
|
||||
from colorswatchpopup import ColorSwatchPopup
|
||||
from osc_sender import send_osc_message
|
||||
from midi_sender import send_cc
|
||||
from extension_sender import send_to_extension
|
||||
|
||||
|
||||
class FocusFaderWidget(QWidget):
|
||||
"""Direct DAW-focus fader — OSC-only output, shown on any iPad in Channel Focus mode, no hardware learn input."""
|
||||
def __init__(self, label):
|
||||
|
||||
super().__init__()
|
||||
|
||||
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Maximum)
|
||||
self.setFixedWidth(115)
|
||||
self.current_color = PALETTE_HEX.get("Gray", "#263238")
|
||||
|
||||
self.uid = str(uuid.uuid4())
|
||||
|
||||
# Pickup mode state
|
||||
self.pickup_mode = False
|
||||
self.last_sent = 64
|
||||
self.prev_position = 64
|
||||
self.hunting = False
|
||||
|
||||
self.is_learning = False
|
||||
self.is_learning_name = 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)
|
||||
|
||||
_grp_style = "QFrame { border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; }"
|
||||
|
||||
# Group: OSC input (feedback from the DAW)
|
||||
self.grp_input = QFrame()
|
||||
self.grp_input.setStyleSheet(_grp_style)
|
||||
_grp_input_layout = QVBoxLayout(self.grp_input)
|
||||
_grp_input_layout.setContentsMargins(4, 4, 4, 4)
|
||||
_grp_input_layout.setSpacing(3)
|
||||
|
||||
self.learn_btn = QPushButton("Learn")
|
||||
self.learn_btn.setFixedHeight(20)
|
||||
self.learn_btn.setStyleSheet("border: none;")
|
||||
_grp_input_layout.addWidget(self.learn_btn)
|
||||
|
||||
self.osc_addr_in_edit = QLineEdit("/track/volume")
|
||||
self.osc_addr_in_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
self.osc_addr_in_edit.setPlaceholderText("/address")
|
||||
self.osc_addr_in_edit.setStyleSheet("border: none;")
|
||||
_grp_input_layout.addWidget(self.osc_addr_in_edit)
|
||||
|
||||
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)
|
||||
_grp_input_layout.addWidget(self.cc_input_lbl)
|
||||
|
||||
inner.addWidget(self.grp_input)
|
||||
|
||||
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: OSC output controls + readout (sent to the DAW on fader change)
|
||||
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)
|
||||
|
||||
# OSC / Ext mode selector
|
||||
mode_row = QHBoxLayout()
|
||||
mode_row.setContentsMargins(0, 0, 0, 0)
|
||||
mode_row.setSpacing(2)
|
||||
self.osc_mode_btn = QPushButton("OSC")
|
||||
self.osc_mode_btn.setFixedHeight(18)
|
||||
self.osc_mode_btn.setCheckable(True)
|
||||
self.osc_mode_btn.setChecked(True)
|
||||
self.osc_mode_btn.setStyleSheet("border: none;")
|
||||
self.ext_mode_btn = QPushButton("Ext")
|
||||
self.ext_mode_btn.setFixedHeight(18)
|
||||
self.ext_mode_btn.setCheckable(True)
|
||||
self.ext_mode_btn.setChecked(False)
|
||||
self.ext_mode_btn.setStyleSheet("border: none;")
|
||||
self.osc_mode_btn.clicked.connect(lambda: self.set_output_mode("osc"))
|
||||
self.ext_mode_btn.clicked.connect(lambda: self.set_output_mode("ext"))
|
||||
mode_row.addWidget(self.osc_mode_btn)
|
||||
mode_row.addWidget(self.ext_mode_btn)
|
||||
_grp2_layout.addLayout(mode_row)
|
||||
|
||||
self.output_mode = "osc"
|
||||
self._last_ext_value = None # tracks previous fader value, for delta-on-move in Ext mode
|
||||
|
||||
# OSC address field
|
||||
self.osc_addr_out_edit = QLineEdit("/track/volume")
|
||||
self.osc_addr_out_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
self.osc_addr_out_edit.setPlaceholderText("/address")
|
||||
self.osc_addr_out_edit.setStyleSheet("border: none;")
|
||||
_grp2_layout.addWidget(self.osc_addr_out_edit)
|
||||
|
||||
# Output readout
|
||||
self.cc_output_lbl = QLabel(self._osc_output_label(64))
|
||||
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)
|
||||
|
||||
# Group: Hardware — optional physical MIDI CC control alongside the
|
||||
# app/OSC control (e.g. a JL Cooper motorized fader), positioned like
|
||||
# grp_remote on the regular widgets rather than up with the OSC learn
|
||||
# section. "Hardware" binds a hw CC that can move this fader; independently,
|
||||
# "Feedback" sends a translated CC back out on every value change (from
|
||||
# hardware, OSC feedback, or the app) so a motorized fader's position stays
|
||||
# in sync — separate switches since you may want feedback without hardware
|
||||
# input, or vice versa.
|
||||
self.hw_cc = None
|
||||
self.hw_channel = None
|
||||
self.is_learning_hw = False
|
||||
|
||||
# Touch sense: some motorized fader hardware (e.g. JL Cooper) sends
|
||||
# touch on/off using the SAME CC as the fader's value, just on
|
||||
# hw_channel - 1 (e.g. value on CH16, touch on CH15). "Off" (default)
|
||||
# ignores this distinction entirely. While touching, incoming OSC/DAW
|
||||
# feedback is ignored (see receive_osc_value) so the motor doesn't
|
||||
# fight your hand.
|
||||
self.touch_sense_mode = "off"
|
||||
self.is_touching = False
|
||||
|
||||
self.grp_hardware = QFrame()
|
||||
self.grp_hardware.setStyleSheet(_grp_style)
|
||||
_grp_hw_layout = QVBoxLayout(self.grp_hardware)
|
||||
_grp_hw_layout.setContentsMargins(4, 4, 4, 4)
|
||||
_grp_hw_layout.setSpacing(3)
|
||||
|
||||
self.hardware_checkbox = QCheckBox("Hardware")
|
||||
self.hardware_checkbox.setChecked(False)
|
||||
self.hardware_checkbox.stateChanged.connect(lambda _: self._update_hw_visibility())
|
||||
_grp_hw_layout.addWidget(self.hardware_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
|
||||
self.hw_learn_btn = QPushButton("Learn")
|
||||
self.hw_learn_btn.setFixedHeight(20)
|
||||
self.hw_learn_btn.setStyleSheet("border: none;")
|
||||
_grp_hw_layout.addWidget(self.hw_learn_btn)
|
||||
|
||||
self.hw_cc_input_lbl = QLabel("")
|
||||
self.hw_cc_input_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
self.hw_cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px; border: none;")
|
||||
self.hw_cc_input_lbl.setFixedHeight(16)
|
||||
_grp_hw_layout.addWidget(self.hw_cc_input_lbl)
|
||||
|
||||
self.touch_sense_combo = QComboBox()
|
||||
self.touch_sense_combo.addItems(["No Profile", "MIDI JL Cooper CC Mode"])
|
||||
self.touch_sense_combo.setStyleSheet("font-size: 10px;")
|
||||
self.touch_sense_combo.currentIndexChanged.connect(self._on_touch_sense_changed)
|
||||
_grp_hw_layout.addWidget(self.touch_sense_combo)
|
||||
|
||||
self.feedback_checkbox = QCheckBox("Feedback")
|
||||
self.feedback_checkbox.setChecked(False)
|
||||
self.feedback_checkbox.stateChanged.connect(lambda _: self._update_hw_visibility())
|
||||
_grp_hw_layout.addWidget(self.feedback_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
|
||||
self.hw_cc_spin = QSpinBox()
|
||||
self.hw_cc_spin.setMinimum(0)
|
||||
self.hw_cc_spin.setMaximum(127)
|
||||
self.hw_cc_spin.setValue(7)
|
||||
self.hw_cc_spin.setFixedWidth(55)
|
||||
_hw_cc_lbl = QLabel("CC")
|
||||
_hw_cc_lbl.setStyleSheet("color: #fff; font-size: 10px; border: none;")
|
||||
self.hw_cc_spin_row = QWidget()
|
||||
_hbox_hw_cc = QHBoxLayout(self.hw_cc_spin_row)
|
||||
_hbox_hw_cc.setContentsMargins(0, 0, 0, 0)
|
||||
_hbox_hw_cc.setSpacing(4)
|
||||
_hbox_hw_cc.addWidget(_hw_cc_lbl)
|
||||
_hbox_hw_cc.addWidget(self.hw_cc_spin)
|
||||
_grp_hw_layout.addWidget(self.hw_cc_spin_row)
|
||||
|
||||
self.hw_ch_spin = QSpinBox()
|
||||
self.hw_ch_spin.setMinimum(1)
|
||||
self.hw_ch_spin.setMaximum(16)
|
||||
self.hw_ch_spin.setValue(1)
|
||||
self.hw_ch_spin.setFixedWidth(55)
|
||||
_hw_ch_lbl = QLabel("CH")
|
||||
_hw_ch_lbl.setStyleSheet("color: #fff; font-size: 10px; border: none;")
|
||||
self.hw_ch_spin_row = QWidget()
|
||||
_hbox_hw_ch = QHBoxLayout(self.hw_ch_spin_row)
|
||||
_hbox_hw_ch.setContentsMargins(0, 0, 0, 0)
|
||||
_hbox_hw_ch.setSpacing(4)
|
||||
_hbox_hw_ch.addWidget(_hw_ch_lbl)
|
||||
_hbox_hw_ch.addWidget(self.hw_ch_spin)
|
||||
_grp_hw_layout.addWidget(self.hw_ch_spin_row)
|
||||
|
||||
self.hw_cc_output_lbl = QLabel(f"CC{self.hw_cc_spin.value()} CH{self.hw_ch_spin.value()} [--]")
|
||||
self.hw_cc_output_lbl.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
self.hw_cc_output_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px; border: none;")
|
||||
self.hw_cc_output_lbl.setFixedHeight(16)
|
||||
_grp_hw_layout.addWidget(self.hw_cc_output_lbl)
|
||||
|
||||
inner.addWidget(self.grp_hardware)
|
||||
|
||||
# Fader
|
||||
self.fader = QSlider(Qt.Orientation.Vertical)
|
||||
self.fader.setMinimum(0)
|
||||
self.fader.setMaximum(127)
|
||||
self.fader.setValue(64)
|
||||
self.fader.setMinimumHeight(200)
|
||||
self.fader.setFixedWidth(40)
|
||||
self.fader.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Expanding)
|
||||
self.fader.setStyleSheet(FADER_STYLE)
|
||||
inner.addWidget(self.fader, alignment=Qt.AlignmentFlag.AlignHCenter)
|
||||
|
||||
# Readout kept for pickup blink logic but not shown
|
||||
self.readout = QLabel(f"{self.osc_addr_out_edit.text()} → {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)
|
||||
|
||||
# Track name learn (binds the scribble strip to live OSC track-name feedback)
|
||||
self.name_learn_btn = QPushButton("Learn Name")
|
||||
self.name_learn_btn.setFixedHeight(18)
|
||||
self.name_learn_btn.setStyleSheet("border: none;")
|
||||
inner.addWidget(self.name_learn_btn)
|
||||
|
||||
self.osc_addr_name_edit = QLineEdit("/track/name")
|
||||
self.osc_addr_name_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
self.osc_addr_name_edit.setPlaceholderText("/address")
|
||||
self.osc_addr_name_edit.setStyleSheet("border: none;")
|
||||
inner.addWidget(self.osc_addr_name_edit)
|
||||
|
||||
# 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.on_select_callback = None
|
||||
self.on_context_menu_callback = None
|
||||
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
|
||||
|
||||
self._update_hw_visibility()
|
||||
|
||||
# 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 event.button() != Qt.MouseButton.RightButton:
|
||||
if self.on_select_callback:
|
||||
self.on_select_callback(self)
|
||||
elif event.type() == QEvent.Type.ContextMenu:
|
||||
if self.on_context_menu_callback:
|
||||
self.on_context_menu_callback(self, event.globalPos())
|
||||
return True
|
||||
return False
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() != Qt.MouseButton.RightButton:
|
||||
if self.on_select_callback:
|
||||
self.on_select_callback(self)
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
if self.on_context_menu_callback:
|
||||
self.on_context_menu_callback(self, event.globalPos())
|
||||
|
||||
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.setText("Learn")
|
||||
self.learn_btn.setStyleSheet("")
|
||||
|
||||
def bind_osc_addr(self, address):
|
||||
self.osc_addr_in_edit.setText(address)
|
||||
self.stop_learn()
|
||||
|
||||
def _update_hw_visibility(self):
|
||||
hw_on = self.hardware_checkbox.isChecked()
|
||||
self.hw_learn_btn.setVisible(hw_on)
|
||||
self.hw_cc_input_lbl.setVisible(hw_on)
|
||||
fb_on = self.feedback_checkbox.isChecked()
|
||||
self.hw_cc_spin_row.setVisible(fb_on)
|
||||
self.hw_ch_spin_row.setVisible(fb_on)
|
||||
self.hw_cc_output_lbl.setVisible(fb_on)
|
||||
|
||||
def start_hw_learn(self):
|
||||
self.is_learning_hw = True
|
||||
self.hw_learn_btn.setText("Listening...")
|
||||
self.hw_learn_btn.setStyleSheet("color: orange; font-weight: bold;")
|
||||
|
||||
def stop_hw_learn(self):
|
||||
self.is_learning_hw = False
|
||||
self.hw_learn_btn.setStyleSheet("")
|
||||
if self.hw_cc is not None:
|
||||
self.hw_learn_btn.setText(f"HW: CC{self.hw_cc}")
|
||||
self.hw_cc_input_lbl.setText(self._hw_cc_input_label())
|
||||
self.hw_cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px;")
|
||||
else:
|
||||
self.hw_learn_btn.setText("Learn")
|
||||
self.hw_cc_input_lbl.setText("")
|
||||
self.hw_cc_input_lbl.setStyleSheet("background-color: #000000; color: #888888; font-size: 10px; padding: 1px;")
|
||||
|
||||
def bind_hw_cc(self, cc_number, channel=None):
|
||||
self.hw_cc = cc_number
|
||||
self.hw_channel = channel
|
||||
self.stop_hw_learn()
|
||||
|
||||
def _hw_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 _on_touch_sense_changed(self, index):
|
||||
self.touch_sense_mode = "jl_cooper" if index == 1 else "off"
|
||||
self.is_touching = False
|
||||
|
||||
def set_output_mode(self, mode):
|
||||
self.output_mode = mode
|
||||
if mode == "osc":
|
||||
self.osc_mode_btn.setChecked(True)
|
||||
self.ext_mode_btn.setChecked(False)
|
||||
self.osc_mode_btn.setStyleSheet("font-weight: bold; color: #00e676;")
|
||||
self.ext_mode_btn.setStyleSheet("")
|
||||
self.osc_addr_out_edit.setVisible(True)
|
||||
self.cc_output_lbl.setText(self._osc_output_label(self.fader.value()))
|
||||
else:
|
||||
self.ext_mode_btn.setChecked(True)
|
||||
self.osc_mode_btn.setChecked(False)
|
||||
self.ext_mode_btn.setStyleSheet("font-weight: bold; color: #00e676;")
|
||||
self.osc_mode_btn.setStyleSheet("")
|
||||
self.osc_addr_out_edit.setVisible(False)
|
||||
self.cc_output_lbl.setText("Ext [--]")
|
||||
self._last_ext_value = None # reset delta tracking on mode switch
|
||||
|
||||
def receive_osc_value(self, value):
|
||||
"""Update the fader to reflect feedback from the DAW, without echoing it back out."""
|
||||
if self.is_touching:
|
||||
# Hardware currently owns the value — don't let DAW/OSC feedback
|
||||
# fight the motor while a hand is physically on the fader.
|
||||
return
|
||||
fader_value = max(0, min(127, round(value * 127)))
|
||||
self.cc_input_lbl.setText(f"[{value:.2f}]")
|
||||
self.cc_input_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 10px; padding: 1px; border: none;")
|
||||
self.fader.blockSignals(True)
|
||||
self.fader.setValue(fader_value)
|
||||
self.fader.blockSignals(False)
|
||||
|
||||
def start_learn_name(self):
|
||||
self.is_learning_name = True
|
||||
self.name_learn_btn.setText("Listening...")
|
||||
self.name_learn_btn.setStyleSheet("color: orange; font-weight: bold;")
|
||||
|
||||
def stop_learn_name(self):
|
||||
self.is_learning_name = False
|
||||
self.name_learn_btn.setText("Learn Name")
|
||||
self.name_learn_btn.setStyleSheet("")
|
||||
|
||||
def bind_osc_name_addr(self, address):
|
||||
self.osc_addr_name_edit.setText(address)
|
||||
self.stop_learn_name()
|
||||
|
||||
def receive_osc_name(self, name):
|
||||
"""Update the scribble strip to reflect the live track name from the DAW."""
|
||||
self.title_edit.setText(name)
|
||||
|
||||
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):
|
||||
if self.output_mode == "ext":
|
||||
self.cc_output_lbl.setText(f"Ext [{value}]")
|
||||
else:
|
||||
self.cc_output_lbl.setText(self._osc_output_label(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
|
||||
self._emit_value(value)
|
||||
else:
|
||||
self.readout.setText(f"[{value}]")
|
||||
return
|
||||
else:
|
||||
self.last_sent = value
|
||||
self.prev_position = value
|
||||
self._emit_value(value)
|
||||
self.readout.setText(f"[{value}]")
|
||||
|
||||
def _emit_value(self, value):
|
||||
"""Sends the current fader value out via whichever protocol is
|
||||
selected. Ext mode sends a delta (change since last move), not the
|
||||
absolute position — the extension's UDP protocol only understands
|
||||
deltas added to the current baseline, not absolute targets."""
|
||||
if self.output_mode == "ext":
|
||||
if self._last_ext_value is None:
|
||||
self._last_ext_value = value
|
||||
delta = (value - self._last_ext_value) / 127.0
|
||||
self._last_ext_value = value
|
||||
if delta != 0:
|
||||
send_to_extension(delta)
|
||||
else:
|
||||
addr = self.osc_addr_out_edit.text().strip() or "/track/volume"
|
||||
send_osc_message(addr, value / 127.0)
|
||||
self._send_hw_feedback(value)
|
||||
|
||||
def _send_hw_feedback(self, value):
|
||||
"""Sends a translated MIDI CC out (e.g. so a motorized fader's position
|
||||
tracks this channel's value), independent of whether hardware CC input
|
||||
is also enabled — you may want feedback without hardware input, or
|
||||
vice versa."""
|
||||
if not self.feedback_checkbox.isChecked():
|
||||
return
|
||||
out_cc = self.hw_cc_spin.value()
|
||||
out_ch = self.hw_ch_spin.value()
|
||||
send_cc(out_cc, value, channel=out_ch)
|
||||
self.hw_cc_output_lbl.setText(f"CC{out_cc} CH{out_ch} [{value}]")
|
||||
|
||||
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 _osc_output_label(self, value="--"):
|
||||
addr = self.osc_addr_out_edit.text().strip() or "/track/volume"
|
||||
if isinstance(value, (int, float)):
|
||||
return f"{addr} [{value / 127.0:.2f}]"
|
||||
return f"{addr} [{value}]"
|
||||
|
||||
def get_state(self):
|
||||
return {
|
||||
"uid": self.uid,
|
||||
"label": self.title_edit.text(),
|
||||
"value": self.fader.value(),
|
||||
"color": self.color_name,
|
||||
"pickup": self.pickup_checkbox.isChecked(),
|
||||
"last_sent": self.last_sent,
|
||||
"osc_addr_in": self.osc_addr_in_edit.text(),
|
||||
"osc_addr_out": self.osc_addr_out_edit.text(),
|
||||
"osc_addr_name": self.osc_addr_name_edit.text(),
|
||||
"zone": self.zone,
|
||||
"center_index": self.center_index,
|
||||
"zone_index": self.zone_index,
|
||||
"hardware_enabled": self.hardware_checkbox.isChecked(),
|
||||
"feedback_enabled": self.feedback_checkbox.isChecked(),
|
||||
"hw_cc": self.hw_cc,
|
||||
"hw_channel": self.hw_channel,
|
||||
"hw_out_cc": self.hw_cc_spin.value(),
|
||||
"hw_out_ch": self.hw_ch_spin.value(),
|
||||
"touch_sense_mode": self.touch_sense_mode,
|
||||
"output_mode": self.output_mode,
|
||||
}
|
||||
|
||||
def set_state(self, state):
|
||||
if "uid" in state:
|
||||
self.uid = state["uid"]
|
||||
self.title_edit.setText(state.get("label", ""))
|
||||
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.osc_addr_in_edit.setText(state.get("osc_addr_in", "/track/volume"))
|
||||
self.osc_addr_out_edit.setText(state.get("osc_addr_out", "/track/volume"))
|
||||
self.osc_addr_name_edit.setText(state.get("osc_addr_name", "/track/name"))
|
||||
self.hw_cc = state.get("hw_cc", None)
|
||||
self.hw_channel = state.get("hw_channel", None)
|
||||
self.hw_cc_spin.setValue(state.get("hw_out_cc", 7))
|
||||
self.hw_ch_spin.setValue(state.get("hw_out_ch", 1))
|
||||
self.stop_hw_learn()
|
||||
self.hardware_checkbox.setChecked(state.get("hardware_enabled", False))
|
||||
self.feedback_checkbox.setChecked(state.get("feedback_enabled", False))
|
||||
# jl_cooper_off/jl_cooper_on were a short-lived three-state naming;
|
||||
# both collapse to the single jl_cooper mode.
|
||||
saved_touch_mode = state.get("touch_sense_mode", "off")
|
||||
self.touch_sense_mode = "jl_cooper" if saved_touch_mode in ("jl_cooper", "jl_cooper_off", "jl_cooper_on") else "off"
|
||||
self.touch_sense_combo.blockSignals(True)
|
||||
self.touch_sense_combo.setCurrentIndex(1 if self.touch_sense_mode == "jl_cooper" else 0)
|
||||
self.touch_sense_combo.blockSignals(False)
|
||||
self.is_touching = False
|
||||
self._update_hw_visibility()
|
||||
self.set_output_mode(state.get("output_mode", "osc"))
|
||||
Reference in New Issue
Block a user