Compare commits

...

2 Commits

Author SHA1 Message Date
Paul Lipscomb 583ed77a67 Update io_config from live app testing (MIDI ports reset to Not Connected)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 20:38:53 -04:00
Paul Lipscomb 0c7b6b4442 JL Cooper MIDI Controller I/O, Focus widget hardware binding, iOS Channel Focus polish
Desktop:
- New dedicated MIDI Controller In port (separate from MIDI In), same CC learn/bind pipeline but never passes notes through; positioned first in the IO column order
- Fixed MIDI In "Not Connected" not persisting across restart (missing save_io_config call)
- Fixed MIDI Out/Transport Out mutual-exclusion incorrectly flagging "Not Connected" as an in-use port
- Renamed "Remote" checkbox to "App" on Fader/Toggle/Transport widgets
- FocusFaderWidget/FocusToggleWidget: new Hardware section (Learn + CC/CH bind) so a physical controller can drive a Channel Focus widget alongside the app/OSC, plus an independent Feedback checkbox that sends translated CC out (for motorized fader / LED sync)
- JL Cooper touch-sense profile ("No Profile" / "MIDI JL Cooper CC Mode") on FaderWidget/FocusFaderWidget: touch channel (value channel - 1, same CC) is filtered out of the value and gates incoming OSC/DAW feedback while touching
- FocusFeedbackWidget: "Custom" checkbox to show a manually-typed static string instead of live OSC feedback, disabling Learn/OSC input and suppressing the iPad broadcast while active
- New daw-config-reaper/ folder with reaper-osc-config-paul-custom.ReaperOSC

iOS:
- New FocusToggleWidget (dedicated Channel Focus toggle, split out of shared ToggleWidget) with colorName-aware darken/border and isMomentary handling — keeps Channel-Focus-only behavior off the regular ToggleWidget
- Arrange mode long-press menu: text alignment (left/center/right) and a 10-color named-palette Text Color picker for TitleWidget/FocusFeedbackWidget, persisted in the saved layout
- FocusFeedbackWidget: Reset Size action, monospaced-digit font fix for timestamp/counter jitter
- New "Cancel Changes" button in Arrange mode — reverts layout/hides/aligns to the state at Arrange-mode entry and restores desktop-side Remote/dest assignments
- Grid line brightness increased for visibility
- Bonjour discovery for desktop auto-connect

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 22:17:37 -04:00
34 changed files with 4815 additions and 208 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
{
"permissions": {
"allow": [
"Bash(git add *)"
"Bash(git add *)",
"Bash(python3 -c \"import ast; ast.parse\\(open\\('main.py'\\).read\\(\\)\\)\")"
]
}
}
+53 -10
View File
@@ -3,7 +3,7 @@ import uuid
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QSlider,
QLabel, QSpinBox, QLineEdit, QFrame, QSizePolicy,
QPushButton, QCheckBox
QPushButton, QCheckBox, QComboBox
)
from PyQt6.QtCore import Qt, QEvent, QTimer
@@ -55,6 +55,15 @@ class FaderWidget(QWidget):
self.hw_channel = None
self.is_learning = 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, matching prior behavior.
# is_touching is tracked either way but only acted on by hardware with
# a real feedback path to gate (see FocusFaderWidget.receive_osc_value).
self.touch_sense_mode = "off"
self.is_touching = False
_grp_style = "QFrame { border: 1px solid rgba(255,255,255,0.2); border-radius: 4px; }"
# Group 1: Learn + input readout (border always visible; learn_btn hideable)
@@ -77,6 +86,12 @@ class FaderWidget(QWidget):
self.cc_input_lbl.setFixedHeight(16)
_grp1_layout.addWidget(self.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)
_grp1_layout.addWidget(self.touch_sense_combo)
inner.addWidget(self.grp_learn)
_arrow_lbl = QLabel("")
@@ -130,7 +145,25 @@ class FaderWidget(QWidget):
inner.addWidget(self.grp_cc)
# Group 3: Remote routing
self.grp_remote = QFrame()
self.grp_remote.setStyleSheet(_grp_style)
_grp_remote_layout = QVBoxLayout(self.grp_remote)
_grp_remote_layout.setContentsMargins(4, 4, 4, 4)
_grp_remote_layout.setSpacing(3)
self.remote_checkbox = QCheckBox("App")
self.remote_checkbox.setChecked(True)
self.remote_checkbox.stateChanged.connect(lambda _: self.dest_spin.setEnabled(self.remote_checkbox.isChecked()))
_grp_remote_layout.addWidget(self.remote_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter)
self.dest_spin = QSpinBox()
self.dest_spin.setRange(1, 9)
self.dest_spin.setValue(1)
self.dest_spin.setFixedWidth(52)
_grp_remote_layout.addWidget(self.dest_spin, alignment=Qt.AlignmentFlag.AlignHCenter)
inner.addWidget(self.grp_remote)
# Fader
self.fader = QSlider(Qt.Orientation.Vertical)
@@ -155,15 +188,6 @@ class FaderWidget(QWidget):
self.pickup_checkbox.stateChanged.connect(self.on_pickup_changed)
inner.addWidget(self.pickup_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter)
# Remote checkbox
self.dest_spin = QSpinBox()
self.dest_spin.setRange(0, 9)
self.dest_spin.setSpecialValueText("Off")
self.dest_spin.setPrefix("T:")
self.dest_spin.setValue(1)
self.dest_spin.setFixedWidth(52)
inner.addWidget(self.dest_spin, alignment=Qt.AlignmentFlag.AlignHCenter)
# Scribble strip
self.title_edit = QLineEdit(label)
self.title_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter)
@@ -432,6 +456,10 @@ class FaderWidget(QWidget):
self.hw_channel = channel
self.stop_learn()
def _on_touch_sense_changed(self, index):
self.touch_sense_mode = "jl_cooper" if index == 1 else "off"
self.is_touching = False
def get_state(self):
return {
"uid": self.uid,
@@ -444,9 +472,11 @@ class FaderWidget(QWidget):
"last_sent": self.last_sent,
"hw_cc": self.hw_cc,
"hw_channel": self.hw_channel,
"touch_sense_mode": self.touch_sense_mode,
"zone": self.zone,
"center_index": self.center_index,
"zone_index": self.zone_index,
"remote": self.remote_checkbox.isChecked(),
"dest_id": self.dest_spin.value(),
}
@@ -474,9 +504,22 @@ class FaderWidget(QWidget):
self.zone_btn.set_state(self.zone == "left", self.zone == "right")
self.hw_cc = state.get("hw_cc", None)
self.hw_channel = state.get("hw_channel", None)
# 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
remote = state.get("remote", True)
self.remote_checkbox.blockSignals(True)
self.remote_checkbox.setChecked(remote)
self.remote_checkbox.blockSignals(False)
self.dest_spin.blockSignals(True)
self.dest_spin.setValue(state.get("dest_id", 1))
self.dest_spin.blockSignals(False)
self.dest_spin.setEnabled(remote)
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())
+601
View File
@@ -0,0 +1,601 @@
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
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 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 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):
addr = self.osc_addr_out_edit.text().strip() or "/track/volume"
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
send_osc_message(addr, value / 127.0)
self._send_hw_feedback(value)
else:
self.readout.setText(f"{addr}{value}")
return
else:
self.last_sent = value
self.prev_position = value
send_osc_message(addr, value / 127.0)
self._send_hw_feedback(value)
self.readout.setText(f"{addr}{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,
}
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()
+226
View File
@@ -0,0 +1,226 @@
import uuid
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QLineEdit, QFrame, QSizePolicy, QCheckBox
)
from PyQt6.QtCore import Qt, QEvent
from palette import PALETTE_HEX, PALETTE_VIVID
from styles import TITLE_STYLE
from colorswatchpopup import ColorSwatchPopup
class FocusFeedbackWidget(QWidget):
"""Read-only OSC feedback display for Channel Focus — Learn an address, show whatever value arrives. No output, no interaction."""
def __init__(self, label):
super().__init__()
self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Preferred)
self.setFixedWidth(115)
self.current_color = PALETTE_HEX.get("Gray", "#263238")
self.uid = str(uuid.uuid4())
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.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("/3/trackname")
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)
# Custom: shows a fixed, manually-typed string instead of live OSC
# feedback — disables Learn and the OSC address input while checked.
self.custom_checkbox = QCheckBox("Custom")
self.custom_checkbox.setChecked(False)
self.custom_checkbox.stateChanged.connect(self._on_custom_changed)
_grp_input_layout.addWidget(self.custom_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter)
self.custom_text_edit = QLineEdit("")
self.custom_text_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter)
self.custom_text_edit.setPlaceholderText("Custom text")
self.custom_text_edit.setStyleSheet("border: none;")
self.custom_text_edit.textChanged.connect(self._on_custom_text_changed)
self.custom_text_edit.setVisible(False)
_grp_input_layout.addWidget(self.custom_text_edit)
inner.addWidget(self.grp_input)
# Readout — the live feedback value
self.readout_lbl = QLabel("")
self.readout_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.readout_lbl.setWordWrap(True)
self.readout_lbl.setStyleSheet("background-color: #000000; color: #00e676; font-size: 14px; font-weight: bold; padding: 6px 2px; border-radius: 4px;")
self.readout_lbl.setMinimumHeight(48)
inner.addWidget(self.readout_lbl)
# Scribble strip (caption — what this feedback represents, e.g. "Track Name")
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)
outer.addWidget(self.container)
self.on_select_callback = None
self.on_context_menu_callback = None
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_custom_visibility(self):
is_custom = self.custom_checkbox.isChecked()
self.learn_btn.setVisible(not is_custom)
self.osc_addr_in_edit.setVisible(not is_custom)
self.custom_text_edit.setVisible(is_custom)
def _on_custom_changed(self, _state):
self._update_custom_visibility()
if self.custom_checkbox.isChecked():
self.readout_lbl.setText(self.custom_text_edit.text())
def _on_custom_text_changed(self, text):
if self.custom_checkbox.isChecked():
self.readout_lbl.setText(text)
def receive_osc_value(self, value):
"""Display whatever feedback arrives on the bound address, verbatim."""
if self.custom_checkbox.isChecked():
return
self.readout_lbl.setText(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 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 get_state(self):
return {
"uid": self.uid,
"label": self.title_edit.text(),
"text": self.readout_lbl.text(),
"color": self.color_name,
"osc_addr_in": self.osc_addr_in_edit.text(),
"custom_enabled": self.custom_checkbox.isChecked(),
"custom_text": self.custom_text_edit.text(),
}
def set_state(self, state):
if "uid" in state:
self.uid = state["uid"]
self.title_edit.setText(state.get("label", ""))
self.readout_lbl.setText(state.get("text", ""))
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.osc_addr_in_edit.setText(state.get("osc_addr_in", "/3/trackname"))
self.custom_text_edit.setText(state.get("custom_text", ""))
self.custom_checkbox.setChecked(state.get("custom_enabled", False))
self._update_custom_visibility()
+491
View File
@@ -0,0 +1,491 @@
import uuid
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QLineEdit, QFrame, QSizePolicy, QCheckBox, QSpinBox
)
from PyQt6.QtCore import Qt, QEvent, QTimer
from palette import PALETTE_HEX, PALETTE_VIVID
from styles import TITLE_STYLE
from zonebutton import ZoneButton
from colorswatchpopup import ColorSwatchPopup
from osc_sender import send_osc_message
from midi_sender import send_cc
class FocusToggleWidget(QWidget):
"""Direct DAW-focus toggle — 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.Preferred)
self.setFixedWidth(115)
self.current_color = PALETTE_HEX.get("Gray", "#263238")
self.toggle_state = False
self.trigger_mode = "toggle"
self.uid = str(uuid.uuid4())
self.zone = None
self.center_index = None
self.zone_index = None
self.on_zone_change_callback = 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.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/mute")
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 click)
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 mode 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(False)
self.momentary_btn.setStyleSheet("border: none;")
self.toggle_mode_btn = QPushButton("Tog.")
self.toggle_mode_btn.setFixedHeight(18)
self.toggle_mode_btn.setCheckable(True)
self.toggle_mode_btn.setChecked(True)
self.toggle_mode_btn.setStyleSheet("font-weight: bold; color: #00e676;")
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.osc_addr_out_edit = QLineEdit("/track/mute")
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)
self.cc_output_lbl = QLabel(self._osc_output_label("--"))
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 button), positioned like grp_remote
# on the regular widgets rather than up with the OSC learn section.
# "Hardware" binds a hw CC/note that can trigger this toggle; independently,
# "Feedback" sends a translated CC back out on every state change (from
# hardware, OSC feedback, or the app) so hardware LEDs/motors stay in
# sync — separate switches since you may want one without the other.
self.hw_cc = None
self.hw_channel = None
self.is_learning_hw = 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.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)
# 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.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
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
self.on_context_menu_callback = None
self._update_hw_visibility()
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 _send_hw_feedback(self, value):
"""Sends a translated MIDI CC out (e.g. so a hardware button's LED
tracks this toggle's state), independent of whether hardware CC input
is also enabled."""
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 receive_osc_value(self, value):
"""Update the toggle to reflect feedback from the DAW, without echoing it back out."""
self.toggle_state = value >= 0.5
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._update_btn_style()
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)
self._update_btn_style()
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 on_click(self):
addr = self.osc_addr_out_edit.text().strip() or "/track/mute"
if self.trigger_mode == "momentary":
send_osc_message(addr, 1.0)
self.cc_output_lbl.setText(self._osc_output_label(1.0))
self.led_dot.setStyleSheet("background-color: #00e676; border-radius: 3px;")
QTimer.singleShot(150, lambda: self.led_dot.setStyleSheet("background-color: #3c3c3c; border-radius: 3px;"))
self._send_hw_feedback(127)
else:
self.toggle_state = not self.toggle_state
value = 1.0 if self.toggle_state else 0.0
send_osc_message(addr, value)
self.cc_output_lbl.setText(self._osc_output_label(value))
self._update_btn_style()
self._send_hw_feedback(127 if self.toggle_state else 0)
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 _osc_output_label(self, value="--"):
addr = self.osc_addr_out_edit.text().strip() or "/track/mute"
if isinstance(value, (int, float)):
return f"{addr} [{value:.1f}]"
return f"{addr} [{value}]"
def get_state(self):
return {
"uid": self.uid,
"label": self.title_edit.text(),
"state": self.toggle_state,
"color": self.color_name,
"zone": self.zone,
"center_index": self.center_index,
"zone_index": self.zone_index,
"trigger_mode": self.trigger_mode,
"osc_addr_in": self.osc_addr_in_edit.text(),
"osc_addr_out": self.osc_addr_out_edit.text(),
"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(),
}
def set_state(self, state):
if "uid" in state:
self.uid = state["uid"]
self.title_edit.setText(state.get("label", ""))
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()
self.cc_output_lbl.setText(self._osc_output_label(1.0 if self.toggle_state else 0.0))
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.set_trigger_mode(state.get("trigger_mode", "toggle"))
self.osc_addr_in_edit.setText(state.get("osc_addr_in", "/track/mute"))
self.osc_addr_out_edit.setText(state.get("osc_addr_out", "/track/mute"))
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))
self._update_hw_visibility()
+799 -18
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -5,6 +5,7 @@ class MidiReceiver(QObject):
midi_out_signal = pyqtSignal(list)
transport_out_signal = pyqtSignal()
hw_cc_signal = pyqtSignal(str, int)
controller_in_signal = pyqtSignal(list)
midi_receiver = MidiReceiver()
+26 -9
View File
@@ -137,6 +137,26 @@ class ToggleWidget(QWidget):
inner.addWidget(self.grp_cc)
# Group 3: Remote routing
self.grp_remote = QFrame()
self.grp_remote.setStyleSheet(_grp_style)
_grp_remote_layout = QVBoxLayout(self.grp_remote)
_grp_remote_layout.setContentsMargins(4, 4, 4, 4)
_grp_remote_layout.setSpacing(3)
self.remote_checkbox = QCheckBox("App")
self.remote_checkbox.setChecked(True)
self.remote_checkbox.stateChanged.connect(lambda _: self.dest_spin.setEnabled(self.remote_checkbox.isChecked()))
_grp_remote_layout.addWidget(self.remote_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter)
self.dest_spin = QSpinBox()
self.dest_spin.setRange(1, 9)
self.dest_spin.setValue(1)
self.dest_spin.setFixedWidth(52)
_grp_remote_layout.addWidget(self.dest_spin, alignment=Qt.AlignmentFlag.AlignHCenter)
inner.addWidget(self.grp_remote)
# Toggle button with LED dot overlay
btn_container = QWidget()
btn_container.setFixedSize(60, 60)
@@ -180,15 +200,6 @@ class ToggleWidget(QWidget):
toggle_btn_row.addWidget(self.plus_btn)
inner.addLayout(toggle_btn_row)
# Remote checkbox
self.dest_spin = QSpinBox()
self.dest_spin.setRange(0, 9)
self.dest_spin.setSpecialValueText("Off")
self.dest_spin.setPrefix("T:")
self.dest_spin.setValue(1)
self.dest_spin.setFixedWidth(52)
inner.addWidget(self.dest_spin, alignment=Qt.AlignmentFlag.AlignHCenter)
# Zone button
self.zone_btn = ZoneButton()
self.zone_btn.left_callback = lambda active: self.on_zone_checked("left", active)
@@ -379,6 +390,7 @@ class ToggleWidget(QWidget):
"hw_cc": self.hw_cc,
"hw_channel": self.hw_channel,
"trigger_mode": self.trigger_mode,
"remote": self.remote_checkbox.isChecked(),
"dest_id": self.dest_spin.value(),
}
@@ -404,9 +416,14 @@ class ToggleWidget(QWidget):
self.hw_cc = state.get("hw_cc", None)
self.hw_channel = state.get("hw_channel", None)
self.set_trigger_mode(state.get("trigger_mode", "toggle"))
remote = state.get("remote", True)
self.remote_checkbox.blockSignals(True)
self.remote_checkbox.setChecked(remote)
self.remote_checkbox.blockSignals(False)
self.dest_spin.blockSignals(True)
self.dest_spin.setValue(state.get("dest_id", 1))
self.dest_spin.blockSignals(False)
self.dest_spin.setEnabled(remote)
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())
+26 -9
View File
@@ -152,6 +152,26 @@ class TransportWidget(QWidget):
self.output_mode = "midi"
# Group 3: Remote routing
self.grp_remote = QFrame()
self.grp_remote.setStyleSheet(_grp_style)
_grp_remote_layout = QVBoxLayout(self.grp_remote)
_grp_remote_layout.setContentsMargins(4, 4, 4, 4)
_grp_remote_layout.setSpacing(3)
self.remote_checkbox = QCheckBox("App")
self.remote_checkbox.setChecked(True)
self.remote_checkbox.stateChanged.connect(lambda _: self.dest_spin.setEnabled(self.remote_checkbox.isChecked()))
_grp_remote_layout.addWidget(self.remote_checkbox, alignment=Qt.AlignmentFlag.AlignHCenter)
self.dest_spin = QSpinBox()
self.dest_spin.setRange(1, 9)
self.dest_spin.setValue(1)
self.dest_spin.setFixedWidth(52)
_grp_remote_layout.addWidget(self.dest_spin, alignment=Qt.AlignmentFlag.AlignHCenter)
inner.addWidget(self.grp_remote)
# Transport button with LED dot
btn_container = QWidget()
btn_container.setFixedSize(60, 60)
@@ -195,15 +215,6 @@ class TransportWidget(QWidget):
btn_row.addWidget(self.plus_btn)
inner.addLayout(btn_row)
# Remote checkbox
self.dest_spin = QSpinBox()
self.dest_spin.setRange(0, 9)
self.dest_spin.setSpecialValueText("Off")
self.dest_spin.setPrefix("T:")
self.dest_spin.setValue(1)
self.dest_spin.setFixedWidth(52)
inner.addWidget(self.dest_spin, alignment=Qt.AlignmentFlag.AlignHCenter)
# Zone button
self.zone_btn = ZoneButton()
self.zone_btn.left_callback = lambda active: self.on_zone_checked("left", active)
@@ -387,6 +398,7 @@ class TransportWidget(QWidget):
"output_mode": self.output_mode,
"osc_addr": self.osc_addr_edit.text(),
"trigger_mode": self.trigger_mode,
"remote": self.remote_checkbox.isChecked(),
"dest_id": self.dest_spin.value(),
}
@@ -414,6 +426,11 @@ class TransportWidget(QWidget):
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"))
remote = state.get("remote", True)
self.remote_checkbox.blockSignals(True)
self.remote_checkbox.setChecked(remote)
self.remote_checkbox.blockSignals(False)
self.dest_spin.blockSignals(True)
self.dest_spin.setValue(state.get("dest_id", 1))
self.dest_spin.blockSignals(False)
self.dest_spin.setEnabled(remote)
@@ -0,0 +1,254 @@
# OSC pattern config file for TouchOSC LogicPad layout.
# See extensive comments in Default.ReaperOSC.
DEVICE_TRACK_COUNT 8
DEVICE_SEND_COUNT 5
DEVICE_RECEIVE_COUNT
DEVICE_FX_COUNT 12
DEVICE_FX_PARAM_COUNT 16
DEVICE_FX_INST_PARAM_COUNT 24
DEVICE_MARKER_COUNT
DEVICE_REGION_COUNT
REAPER_TRACK_FOLLOWS DEVICE
#important for osc to follow reaper
DEVICE_TRACK_FOLLOWS LAST_TOUCHED
DEVICE_TRACK_BANK_FOLLOWS DEVICE
DEVICE_FX_FOLLOWS DEVICE
# default
#REAPER_TRACK_FOLLOWS REAPER
#DEVICE_TRACK_FOLLOWS DEVICE
#DEVICE_TRACK_BANK_FOLLOWS DEVICE
#DEVICE_FX_FOLLOWS DEVICE
DEVICE_ROTARY_CENTER 0.0
# ----------------------------------------------------------------
SCROLL_X-
SCROLL_X+
SCROLL_Y-
SCROLL_Y+
ZOOM_X-
ZOOM_X+
ZOOM_Y-
ZOOM_Y+
TIME s/1/time s/2/time s/3/time s/4/time s/5/time
BEAT s/1/bar s/2/bar s/3/bar s/4/bar s/5/bar
SAMPLES
FRAMES
METRONOME t/1/click t/2/click t/3/click t/4/click t/5/click
REPLACE t/1/replace t/2/replace t/3/replace t/4/replace t/5/replace
REPEAT t/1/cycle t/2/cycle t/3/cycle t/4/cycle t/5/cycle
RECORD t/1/record t/2/record t/3/record t/4/record t/5/record
STOP t/1/stop t/2/stop t/3/stop t/4/stop t/5/stop
PLAY t/1/play t/2/play t/3/play t/4/play t/5/play
PAUSE
AUTO_REC_ARM t/1/sel-rec t/2/sel-rec
SOLO_RESET t/1/soloreset t/2/soloreset
ANY_SOLO t/1/ledanysolo t/2/ledanysolo
REWIND b/rewind
FORWARD b/forward
REWIND_FORWARD_BYMARKER t/1/bymarker t/2/bymarker
REWIND_FORWARD_SETLOOP t/1/bycycle t/2/bycycle
GOTO_MARKER
GOTO_REGION
SCRUB
PLAY_RATE
TEMPO
MARKER_NAME
MARKER_NUMBER
REGION_NAME
REGION_NUMBER
LAST_MARKER_NAME
LAST_MARKER_NUMBER
LAST_REGION_NAME
LAST_REGION_NUMBER
MASTER_VOLUME n/1/mastervolume s/1/masterlevel
MASTER_PAN
MASTER_VU
MASTER_VU_L
MASTER_VU_R
MASTER_SEND_NAME s/1/auxname@ s/2/auxname@
MASTER_SEND_VOLUME n/1/auxvolume@ n/2/auxvolume@ s/1/auxlevel@ s/2/auxlevel@
MASTER_SEND_PAN
TRACK_NAME s/1/trackname@ s/2/trackname@ s/3/trackname s/4/trackname s/5/trackname
TRACK_NUMBER s/1/track#@ s/2/track#@ s/3/track# s/4/track# s/5/track#
TRACK_MUTE b/1/mute/1/@ b/2/mute/1/@ t/3/mute
TRACK_SOLO b/1/solo/1/@ b/2/solo/1/@ t/3/solo
TRACK_REC_ARM t/1/recenable/1/@ t/2/recenable/1/@ t/3/recenable
TRACK_MONITOR t/3/input
TRACK_SELECT b/1/select/1/@ b/2/select/1/@
TRACK_VU
TRACK_VU_L
TRACK_VU_R
TRACK_VOLUME n/1/volume@ n/3/volume s/1/level@ s/3/trkvolval
TRACK_PAN n/2/pan@ n/3/pan s/2/panval@ s/3/trkpanval
TRACK_PAN2
TRACK_PAN_MODE
TRACK_SEND_NAME s/2/sendname@@ s/3/sendname@
TRACK_SEND_VOLUME n/2/send@@ n/3/sendlevel@ s/2/sendval@@ s/3/sendval@
TRACK_SEND_PAN
TRACK_RECV_NAME
TRACK_RECV_VOLUME
TRACK_RECV_VOLUME
TRACK_RECV_PAN
TRACK_RECV_PAN
TRACK_AUTO
TRACK_AUTO_TRIM t/3/atmoff
TRACK_AUTO_READ t/3/atmread
TRACK_AUTO_LATCH t/3/atmlatch
TRACK_AUTO_TOUCH t/3/atmtouch
TRACK_AUTO_WRITE t/3/atmwrite
TRACK_VOLUME_TOUCH
TRACK_PAN_TOUCH
FX_NAME s/3/pluginname s/3/insertname@
FX_NUMBER
FX_BYPASS b/3/insertbypass/@/1
FX_OPEN_UI
FX_PRESET
FX_PREV_PRESET
FX_NEXT_PRESET
FX_PARAM_NAME s/3/parname@
FX_WETDRY
FX_PARAM_VALUE n/3/par@ s/3/value@
FX_EQ_BYPASS
FX_EQ_OPEN_UI
FX_EQ_PRESET
FX_EQ_PREV_PRESET
FX_EQ_NEXT_PRESET
FX_EQ_MASTER_GAIN n/4/eqmstgain
FX_EQ_WETDRY
FX_EQ_HIPASS_NAME s/4/label165
FX_EQ_HIPASS_FREQ n/4/hpffrq s/4/hpffrqval
FX_EQ_HIPASS_Q n/4/hpfq s/4/hpfqval
FX_EQ_LOSHELF_NAME s/4/label159
FX_EQ_LOSHELF_FREQ n/4/loslvfrq s/4/loslvfrqval
FX_EQ_LOSHELF_GAIN n/4/gain/1 s/4/loslvgainval
FX_EQ_LOSHELF_Q n/4/loslvq s/4/loslvqval
FX_EQ_BAND_NAME s/4/label160 s/4/label161 s/4/label162
FX_EQ_BAND_FREQ n/4/lomidfrq s/4/lomidfrqval
FX_EQ_BAND_GAIN n/4/gain/3 s/5/lomidgainval
FX_EQ_BAND_Q n/4/lomidq s/5/lomidqval
FX_EQ_NOTCH_NAME s/4/label163
FX_EQ_NOTCH_FREQ n/4/hifrq s/4/hifrqval
FX_EQ_NOTCH_GAIN n/4/gain/5 s/4/higainval
FX_EQ_NOTCH_Q n/4/hiq s/4/hiqval
FX_EQ_HISHELF_NAME s/4/label164
FX_EQ_HISHELF_FREQ n/4/hislvfrq s/4/hislvfrqval
FX_EQ_HISHELF_GAIN n/4/gain/6 s/4/hislvgainval
FX_EQ_HISHELF_Q n/4/hislvq s/4/hislvqval
FX_EQ_LOPASS_NAME s/4/label166
FX_EQ_LOPASS_FREQ n/4/lpffrq s/4/lpffrqval
FX_EQ_LOPASS_Q n/4/lpfq s/4/lpfqval
FX_INST_NAME s/5/pluginname
FX_INST_BYPASS
FX_INST_OPEN_UI
FX_INST_PRESET
FX_INST_PREV_PRESET
FX_INST_NEXT_PRESET
FX_INST_PARAM_NAME s/5/parname@
FX_INST_PARAM_VALUE n/5/par@ s/5/parval@
LAST_TOUCHED_FX_TRACK_NAME
LAST_TOUCHED_FX_TRACK_NUMBER
LAST_TOUCHED_FX_NAME
LAST_TOUCHED_FX_NUMBER
LAST_TOUCHED_FX_PARAM_NAME
LAST_TOUCHED_FX_PARAM_VALUE
ACTION
MIDIACTION
MIDILISTACTION
# ----------------------------------------------------------------
DEVICE_TRACK_COUNT
DEVICE_SEND_COUNT
DEVICE_RECEIVE_COUNT
DEVICE_FX_COUNT
DEVICE_FX_PARAM_COUNT
DEVICE_FX_INST_PARAM_COUNT
DEVICE_MARKER_COUNT
DEVICE_REGION_COUNT
REAPER_TRACK_FOLLOWS
REAPER_TRACK_FOLLOWS_REAPER
REAPER_TRACK_FOLLOWS_DEVICE
DEVICE_TRACK_FOLLOWS
DEVICE_TRACK_FOLLOWS_DEVICE
DEVICE_TRACK_FOLLOWS_LAST_TOUCHED
DEVICE_TRACK_BANK_FOLLOWS
DEVICE_TRACK_BANK_FOLLOWS_DEVICE
DEVICE_TRACK_BANK_FOLLOWS_MIXER
DEVICE_FX_FOLLOWS
DEVICE_FX_FOLLOWS_DEVICE
DEVICE_FX_FOLLOWS_LAST_TOUCHED
DEVICE_FX_FOLLOWS_FOCUSED
DEVICE_TRACK_SELECT
DEVICE_PREV_TRACK t/3/track- t/4/track- t/5/track-
DEVICE_NEXT_TRACK t/3/track+ t/4/track+ t/5/track+
DEVICE_TRACK_BANK_SELECT
DEVICE_PREV_TRACK_BANK t/1/bank- t/2/bank-
DEVICE_NEXT_TRACK_BANK t/1/bank+ t/2/bank+
DEVICE_FX_SELECT b/3/mInsertEdit/@/1
DEVICE_PREV_FX
DEVICE_NEXT_FX
DEVICE_FX_PARAM_BANK_SELECT s/3/page#
DEVICE_PREV_FX_PARAM_BANK t/3/page-
DEVICE_NEXT_FX_PARAM_BANK t/3/page+
DEVICE_FX_INST_PARAM_BANK_SELECT s/5/page#
DEVICE_PREV_FX_INST_PARAM_BANK t/5/page-
DEVICE_NEXT_FX_INST_PARAM_BANK t/5/page+
DEVICE_MARKER_BANK_SELECT
DEVICE_PREV_MARKER_BANK
DEVICE_NEXT_MARKER_BANK
DEVICE_REGION_BANK_SELECT
DEVICE_PREV_REGION_BANK
DEVICE_NEXT_REGION_BANK
+511 -63
View File
@@ -1,55 +1,58 @@
{
"__last_used__": "rename whatever",
"__last_used__": "preset5",
"presets": {
"preset1": {
"faders": [
{
"uid": "c98d2009-d186-489a-8883-ccbd612cb4af",
"label": "Fader 1",
"label": "fuckin a",
"cc": 1,
"ch": 1,
"value": 69,
"value": 117,
"color": "Red",
"pickup": false,
"last_sent": 69,
"last_sent": 117,
"hw_cc": null,
"hw_channel": null,
"zone": null,
"center_index": null,
"zone_index": null,
"remote": true
"remote": true,
"dest_id": 1
},
{
"uid": "7bf9e219-b7a6-4d8e-b317-da9eb18a8095",
"label": "Fader 2",
"cc": 1,
"ch": 1,
"value": 96,
"value": 107,
"color": "Gray",
"pickup": false,
"last_sent": 96,
"last_sent": 107,
"hw_cc": null,
"hw_channel": null,
"zone": "right",
"center_index": 1,
"zone_index": -1,
"remote": true
"remote": true,
"dest_id": 1
},
{
"uid": "bd0c9b6a-db81-411d-88b0-5ebae3c4e790",
"label": "Fader 3",
"cc": 1,
"ch": 1,
"value": 56,
"value": 83,
"color": "Orange",
"pickup": false,
"last_sent": 56,
"last_sent": 83,
"hw_cc": null,
"hw_channel": null,
"zone": "left",
"center_index": 2,
"zone_index": -1,
"remote": true
"remote": true,
"dest_id": 1
}
],
"toggles": [
@@ -66,14 +69,15 @@
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle",
"remote": true
"remote": true,
"dest_id": 1
},
{
"uid": "73962e37-53b3-400f-a7e2-25ef7f1ea583",
"label": "Toggle 2",
"cc": 20,
"ch": 1,
"state": true,
"state": false,
"color": "Blue",
"zone": "left",
"center_index": 1,
@@ -81,7 +85,8 @@
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle",
"remote": true
"remote": true,
"dest_id": 1
},
{
"uid": "b3cf419a-5183-4272-bd6f-44f858afbec8",
@@ -96,7 +101,8 @@
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "momentary",
"remote": true
"remote": true,
"dest_id": 1
},
{
"uid": "c179bfea-deec-416a-b4aa-74e81b5cbe03",
@@ -111,14 +117,15 @@
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle",
"remote": true
"remote": true,
"dest_id": 1
},
{
"uid": "a5a50618-fc4c-446e-868e-e4eafa6254e7",
"label": "Toggle 6",
"cc": 20,
"ch": 1,
"state": true,
"state": false,
"color": "Gray",
"zone": null,
"center_index": null,
@@ -126,7 +133,8 @@
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle",
"remote": true
"remote": true,
"dest_id": 1
},
{
"uid": "f1775380-3390-4fbb-bd8b-8c30ff18f72a",
@@ -141,7 +149,8 @@
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle",
"remote": true
"remote": true,
"dest_id": 1
},
{
"uid": "2064f2ca-6d18-468b-bf45-9bcb1fa1247d",
@@ -156,7 +165,8 @@
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle",
"remote": true
"remote": true,
"dest_id": 1
}
],
"browser": {
@@ -207,18 +217,44 @@
"center_index": null,
"zone_index": null,
"hw_cc": null,
"hw_channel": null
"hw_channel": null,
"trigger_mode": "toggle",
"remote": true,
"dest_id": 1
}
],
"browser": {
"back_cc": 22,
"fwd_cc": 23
"back": {
"label": "Back",
"cc": 22,
"ch": 1,
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "momentary"
},
"fwd": {
"label": "Fwd",
"cc": 23,
"ch": 1,
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "momentary"
}
},
"big_title": "",
"preset_uuid": "30ff84d1-9240-41f6-b499-1d18ef7ab2eb",
"show_hide": {
"fader": false,
"toggle": false,
"transport": false
"transport": false,
"browser": false
},
"sections": {
"preset_browser": true,
"big_title": true,
"fader_row": true,
"toggle_row": true,
"transport_row": true
}
},
"preset3": {
@@ -237,7 +273,8 @@
"zone": "left",
"center_index": 0,
"zone_index": -1,
"remote": true
"remote": true,
"dest_id": 1
},
{
"uid": "7bf9e219-b7a6-4d8e-b317-da9eb18a8095",
@@ -253,23 +290,25 @@
"zone": null,
"center_index": null,
"zone_index": null,
"remote": true
"remote": true,
"dest_id": 1
},
{
"uid": "bd0c9b6a-db81-411d-88b0-5ebae3c4e790",
"label": "Fader 3",
"cc": 1,
"ch": 1,
"value": 93,
"value": 48,
"color": "Yellow",
"pickup": false,
"last_sent": 93,
"last_sent": 48,
"hw_cc": null,
"hw_channel": null,
"zone": "right",
"center_index": 2,
"zone_index": -1,
"remote": true
"remote": true,
"dest_id": 1
}
],
"toggles": [
@@ -286,7 +325,8 @@
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle",
"remote": true
"remote": true,
"dest_id": 1
}
],
"browser": {
@@ -311,7 +351,7 @@
"preset_uuid": "7483fb5b-f47f-49e4-bccb-e8d20195078c",
"show_hide": {
"fader": false,
"toggle": false,
"toggle": true,
"transport": true,
"browser": true
},
@@ -367,118 +407,132 @@
"label": "Fader 1",
"cc": 90,
"ch": 15,
"value": 103,
"color": "Gray",
"value": 50,
"color": "Yellow",
"pickup": false,
"last_sent": 103,
"last_sent": 50,
"hw_cc": null,
"hw_channel": null,
"touch_sense_mode": "off",
"zone": null,
"center_index": null,
"zone_index": null,
"remote": true
"remote": true,
"dest_id": 1
},
{
"uid": "5aaecf41-3455-4fa2-bef1-ca02f5933a69",
"label": "Fader 2",
"label": "VERB",
"cc": 1,
"ch": 1,
"value": 89,
"color": "Gray",
"value": 57,
"color": "Orange",
"pickup": false,
"last_sent": 89,
"last_sent": 57,
"hw_cc": null,
"hw_channel": null,
"touch_sense_mode": "off",
"zone": null,
"center_index": null,
"zone_index": null,
"remote": true
"remote": true,
"dest_id": 1
},
{
"uid": "e8e8cca8-3bad-49a5-8c91-d3a78d25a1eb",
"label": "Fader 3",
"label": "VELOCITY",
"cc": 1,
"ch": 1,
"value": 64,
"color": "Gray",
"color": "Yellow",
"pickup": false,
"last_sent": 64,
"hw_cc": null,
"hw_channel": null,
"touch_sense_mode": "off",
"zone": null,
"center_index": null,
"zone_index": null,
"remote": true
"remote": true,
"dest_id": 1
},
{
"uid": "ae9cd47a-96af-45a4-83e6-816916a65e0e",
"label": "Fader 4",
"label": "oh shit",
"cc": 1,
"ch": 1,
"value": 64,
"value": 76,
"color": "Gray",
"pickup": false,
"last_sent": 64,
"last_sent": 76,
"hw_cc": null,
"hw_channel": null,
"touch_sense_mode": "off",
"zone": null,
"center_index": null,
"zone_index": null,
"remote": true
"remote": true,
"dest_id": 1
},
{
"uid": "fee53fde-2f59-4800-8873-3d75ca619366",
"label": "Fader 5",
"cc": 1,
"ch": 1,
"value": 64,
"value": 0,
"color": "Gray",
"pickup": false,
"last_sent": 64,
"last_sent": 0,
"hw_cc": null,
"hw_channel": null,
"touch_sense_mode": "off",
"zone": null,
"center_index": null,
"zone_index": null,
"remote": true
"remote": true,
"dest_id": 1
},
{
"uid": "1783a98d-16c9-4a06-ae4d-0f6ab36d4421",
"label": "WOOOOW",
"cc": 1,
"ch": 1,
"value": 64,
"value": 36,
"color": "Gray",
"pickup": false,
"last_sent": 64,
"last_sent": 36,
"hw_cc": null,
"hw_channel": null,
"touch_sense_mode": "off",
"zone": null,
"center_index": null,
"zone_index": null,
"remote": true
"remote": true,
"dest_id": 1
},
{
"uid": "2a55bdef-83aa-458f-a37d-e12a61e3e6a0",
"label": "Fader 7",
"cc": 1,
"ch": 1,
"value": 64,
"value": 18,
"color": "Gray",
"pickup": false,
"last_sent": 64,
"last_sent": 18,
"hw_cc": null,
"hw_channel": null,
"touch_sense_mode": "off",
"zone": null,
"center_index": null,
"zone_index": null,
"remote": true
"remote": true,
"dest_id": 1
}
],
"toggles": [
{
"uid": "46f17f40-1bb2-4527-b6c5-fd26e535beed",
"label": "Toggle 1",
"label": "whatever tap",
"cc": 20,
"ch": 1,
"state": false,
@@ -489,7 +543,8 @@
"hw_cc": null,
"hw_channel": null,
"trigger_mode": "toggle",
"remote": true
"remote": true,
"dest_id": 1
}
],
"browser": {
@@ -515,7 +570,7 @@
"show_hide": {
"fader": false,
"toggle": true,
"transport": true,
"transport": false,
"browser": true
},
"sections": {
@@ -789,6 +844,7 @@
"output_mode": "osc",
"osc_addr": "t/1/play",
"trigger_mode": "momentary",
"remote": true,
"dest_id": 1
},
{
@@ -804,6 +860,7 @@
"output_mode": "osc",
"osc_addr": "t/1/stop",
"trigger_mode": "momentary",
"remote": true,
"dest_id": 1
},
{
@@ -819,6 +876,7 @@
"output_mode": "osc",
"osc_addr": "t/1/record",
"trigger_mode": "momentary",
"remote": true,
"dest_id": 1
},
{
@@ -834,16 +892,406 @@
"output_mode": "osc",
"osc_addr": "t/1/recenable/1/@",
"trigger_mode": "momentary",
"remote": true,
"dest_id": 1
},
{
"uid": "4208c3e3-c0b0-4c51-a426-be90568c904a",
"label": "<",
"cc": 20,
"state": false,
"color": "Gray",
"zone": null,
"center_index": null,
"zone_index": null,
"hw_cc": null,
"output_mode": "osc",
"osc_addr": "/3/track-",
"trigger_mode": "momentary",
"remote": true,
"dest_id": 1
},
{
"uid": "7a029b92-9cc0-4534-8657-6abe24c592e0",
"label": ">",
"cc": 20,
"state": false,
"color": "Gray",
"zone": null,
"center_index": null,
"zone_index": null,
"hw_cc": null,
"output_mode": "osc",
"osc_addr": "/3/track+",
"trigger_mode": "momentary",
"remote": true,
"dest_id": 1
}
],
"transport_show_hide": true,
"transport_show_hide": false,
"io_config": {
"midi_out": "IAC Driver Bus 5",
"midi_out": "Not Connected",
"midi_in": "Not Connected",
"midi_controller_in": "Not Connected",
"transport_out": "Not Connected",
"osc_ip": "127.0.0.1",
"osc_listen_port": 9000,
"osc_send_port": 8000
"osc_listen_port": 9001,
"osc_send_port": 8000,
"osc_running": true,
"tablet_running": true
},
"focus_toggles": [
{
"uid": "6aa50c87-25fa-45a9-8b21-22ca6823bfef",
"label": "MUTE",
"state": false,
"color": "Red",
"zone": null,
"center_index": null,
"zone_index": null,
"trigger_mode": "momentary",
"osc_addr_in": "/3/mute",
"osc_addr_out": "/3/mute",
"hardware_enabled": false,
"feedback_enabled": false,
"hw_cc": null,
"hw_channel": null,
"hw_out_cc": 7,
"hw_out_ch": 1
},
{
"uid": "01d26539-51b9-4413-b180-5b012f212e66",
"label": "SOLO",
"state": false,
"color": "Yellow",
"zone": null,
"center_index": null,
"zone_index": null,
"trigger_mode": "momentary",
"osc_addr_in": "/3/solo",
"osc_addr_out": "/3/solo",
"hardware_enabled": false,
"feedback_enabled": false,
"hw_cc": null,
"hw_channel": null,
"hw_out_cc": 7,
"hw_out_ch": 1
},
{
"uid": "888310d7-efd8-4ca7-b5e6-9d62a2ba7d5c",
"label": "WRITE",
"state": false,
"color": "Red",
"zone": null,
"center_index": null,
"zone_index": null,
"trigger_mode": "momentary",
"osc_addr_in": "/3/atmwrite",
"osc_addr_out": "/3/atmwrite",
"hardware_enabled": false,
"feedback_enabled": false,
"hw_cc": null,
"hw_channel": null,
"hw_out_cc": 7,
"hw_out_ch": 1
},
{
"uid": "21f875c2-d49e-484f-b0cd-5a9a5f89b0f9",
"label": "OFF",
"state": true,
"color": "White",
"zone": null,
"center_index": null,
"zone_index": null,
"trigger_mode": "momentary",
"osc_addr_in": "/3/atmoff",
"osc_addr_out": "/3/atmoff",
"hardware_enabled": false,
"feedback_enabled": false,
"hw_cc": null,
"hw_channel": null,
"hw_out_cc": 7,
"hw_out_ch": 1
},
{
"uid": "180759b7-e68b-4b8a-aa11-ee19d2a87fa3",
"label": "TOUCH",
"state": false,
"color": "Yellow",
"zone": null,
"center_index": null,
"zone_index": null,
"trigger_mode": "momentary",
"osc_addr_in": "/3/atmtouch",
"osc_addr_out": "/3/atmtouch",
"hardware_enabled": false,
"feedback_enabled": false,
"hw_cc": null,
"hw_channel": null,
"hw_out_cc": 7,
"hw_out_ch": 1
},
{
"uid": "5d9b7b3a-2429-4dd1-bcf2-043a6db9135d",
"label": "LISTEN",
"state": false,
"color": "Green",
"zone": null,
"center_index": null,
"zone_index": null,
"trigger_mode": "momentary",
"osc_addr_in": "/3/input",
"osc_addr_out": "/3/input",
"hardware_enabled": false,
"feedback_enabled": false,
"hw_cc": null,
"hw_channel": null,
"hw_out_cc": 7,
"hw_out_ch": 1
},
{
"uid": "0916bf53-ac82-4fb2-9842-835aea39e7d5",
"label": "ARM",
"state": true,
"color": "Red",
"zone": null,
"center_index": null,
"zone_index": null,
"trigger_mode": "momentary",
"osc_addr_in": "/3/recenable",
"osc_addr_out": "/3/recenable",
"hardware_enabled": false,
"feedback_enabled": false,
"hw_cc": null,
"hw_channel": null,
"hw_out_cc": 7,
"hw_out_ch": 1
},
{
"uid": "5433708f-5566-40a8-9333-f3061a07c217",
"label": "READ",
"state": false,
"color": "Green",
"zone": null,
"center_index": null,
"zone_index": null,
"trigger_mode": "momentary",
"osc_addr_in": "/3/atmread",
"osc_addr_out": "/3/atmread",
"hardware_enabled": false,
"feedback_enabled": false,
"hw_cc": null,
"hw_channel": null,
"hw_out_cc": 7,
"hw_out_ch": 1
},
{
"uid": "d8741393-ec45-4bc2-a9a0-6dd7dbbe04dd",
"label": "<",
"state": false,
"color": "Gray",
"zone": null,
"center_index": null,
"zone_index": null,
"trigger_mode": "momentary",
"osc_addr_in": "",
"osc_addr_out": "/3/track-",
"hardware_enabled": true,
"feedback_enabled": false,
"hw_cc": 39,
"hw_channel": 16,
"hw_out_cc": 7,
"hw_out_ch": 1
},
{
"uid": "5f951196-c69a-43f2-b5c3-0bf263128c69",
"label": ">",
"state": false,
"color": "Gray",
"zone": null,
"center_index": null,
"zone_index": null,
"trigger_mode": "momentary",
"osc_addr_in": "",
"osc_addr_out": "/3/track+",
"hardware_enabled": true,
"feedback_enabled": false,
"hw_cc": 40,
"hw_channel": 16,
"hw_out_cc": 7,
"hw_out_ch": 1
},
{
"uid": "47a463c5-83c9-4e93-ace7-a488e0a20647",
"label": "Play",
"state": true,
"color": "Green",
"zone": null,
"center_index": null,
"zone_index": null,
"trigger_mode": "momentary",
"osc_addr_in": "/track/mute",
"osc_addr_out": "t/1/play",
"hardware_enabled": true,
"feedback_enabled": false,
"hw_cc": 48,
"hw_channel": 16,
"hw_out_cc": 7,
"hw_out_ch": 1
},
{
"uid": "64a0aa02-4d41-4da2-b35d-c32a04d95157",
"label": "Stop",
"state": false,
"color": "White",
"zone": null,
"center_index": null,
"zone_index": null,
"trigger_mode": "momentary",
"osc_addr_in": "/track/mute",
"osc_addr_out": "t/1/stop",
"hardware_enabled": false,
"feedback_enabled": false,
"hw_cc": null,
"hw_channel": null,
"hw_out_cc": 7,
"hw_out_ch": 1
},
{
"uid": "c0cb0422-b095-4d2d-bafa-b3c48da537d3",
"label": "Record",
"state": false,
"color": "Red",
"zone": null,
"center_index": null,
"zone_index": null,
"trigger_mode": "momentary",
"osc_addr_in": "/track/mute",
"osc_addr_out": "t/1/record",
"hardware_enabled": false,
"feedback_enabled": false,
"hw_cc": null,
"hw_channel": null,
"hw_out_cc": 7,
"hw_out_ch": 1
}
],
"focus_feedbacks": [
{
"uid": "f61354e9-96f7-4826-975b-faea732c487c",
"label": "track name",
"text": "piano",
"color": "Gray",
"osc_addr_in": "/3/trackname",
"custom_enabled": false,
"custom_text": ""
},
{
"uid": "8e77be47-8d5a-4eda-906e-db978cd2207e",
"label": "track number",
"text": "6",
"color": "Gray",
"osc_addr_in": "/3/track#",
"custom_enabled": false,
"custom_text": ""
},
{
"uid": "c97ec8c9-1b97-48f4-9744-62f8a2faec28",
"label": "fader val",
"text": "-7.13dB",
"color": "Gray",
"osc_addr_in": "/3/trkvolval",
"custom_enabled": false,
"custom_text": ""
},
{
"uid": "28956bcf-b147-4e5b-a8c9-ec07d7be70e3",
"label": "time",
"text": "15:29.000",
"color": "Gray",
"osc_addr_in": "/1/time",
"custom_enabled": false,
"custom_text": ""
},
{
"uid": "0916a793-6adf-43b9-a2ea-8fca7ddafc57",
"label": "bar | beat",
"text": "465.3.00",
"color": "Gray",
"osc_addr_in": "/3/bar",
"custom_enabled": false,
"custom_text": ""
},
{
"uid": "7085f9f7-31dd-4214-9e4d-84b9618850ae",
"label": "Send 1 Val",
"text": "-10.3dB",
"color": "Gray",
"osc_addr_in": "/3/sendval1",
"custom_enabled": false,
"custom_text": ""
},
{
"uid": "506eaa2e-8ff4-4e86-a445-f00a46c037df",
"label": "2MX Send",
"text": "2MX SEND",
"color": "Gray",
"osc_addr_in": "/3/trackname",
"custom_enabled": true,
"custom_text": "2MX SEND"
},
{
"uid": "5259b459-4ba4-478d-b35e-4785bb6d75b0",
"label": "Feedback 8",
"text": "Track",
"color": "Gray",
"osc_addr_in": "/3/trackname",
"custom_enabled": true,
"custom_text": "Track"
}
],
"focus_faders": [
{
"uid": "18558185-7cd4-4c2a-995e-4524e69d99e1",
"label": "piano",
"value": 73,
"color": "Green",
"pickup": false,
"last_sent": 62,
"osc_addr_in": "/3/volume",
"osc_addr_out": "/3/volume",
"osc_addr_name": "/3/trackname",
"zone": null,
"center_index": null,
"zone_index": null,
"hardware_enabled": true,
"feedback_enabled": true,
"hw_cc": 8,
"hw_channel": 16,
"hw_out_cc": 8,
"hw_out_ch": 16,
"touch_sense_mode": "jl_cooper"
},
{
"uid": "279a2c81-6548-422e-bf6d-75119c81b9c3",
"label": "2MX",
"value": 65,
"color": "Gray",
"pickup": false,
"last_sent": 42,
"osc_addr_in": "/3/sendlevel1",
"osc_addr_out": "/3/sendlevel1",
"osc_addr_name": "/track/name",
"zone": null,
"center_index": null,
"zone_index": null,
"hardware_enabled": true,
"feedback_enabled": true,
"hw_cc": 7,
"hw_channel": 16,
"hw_out_cc": 7,
"hw_out_ch": 16,
"touch_sense_mode": "jl_cooper"
}
]
}
@@ -0,0 +1,231 @@
import UIKit
class FaderTestVC: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = UIColor(white: 0.08, alpha: 1)
let colors: [UIColor] = [
UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1),
UIColor(red: 0, green: 0.6, blue: 1, alpha: 1),
UIColor(red: 1, green: 0.55, blue: 0, alpha: 1),
]
let padW: CGFloat = 98
let padH: CGFloat = 540
let spacing: CGFloat = 20
let totalW = CGFloat(colors.count) * padW + CGFloat(colors.count - 1) * spacing
let startX = (UIScreen.main.bounds.width - totalW) / 2
let startY = (UIScreen.main.bounds.height - padH) / 2
for (i, color) in colors.enumerated() {
let x = startX + CGFloat(i) * (padW + spacing)
let pad = XYPadView(label: "PAD \(i)", color: color,
frame: CGRect(x: x, y: startY, width: padW, height: padH))
view.addSubview(pad)
}
}
}
class XYPadView: UIView {
private let label: String
private let color: UIColor
private let padHeight: CGFloat = 90
private var isRelative = true
private var currentValue: Float = 0
private var touchStartY: CGFloat = 0
private var valueAtTouchStart: Float = 0
private var didInitialLayout = false
private var lastHapticValue: Int = -1
private let haptic = UIImpactFeedbackGenerator(style: .medium)
private let topZone = UIView()
private let botZone = UIView()
private let fillView = UIView()
private let gradLayer = CAGradientLayer()
private let line = UIView()
private let dot = UIView()
private let nameLabel: UILabel = {
let lbl = UILabel()
lbl.textAlignment = .center
lbl.font = .systemFont(ofSize: 13, weight: .medium)
return lbl
}()
private lazy var modeButton: UIButton = {
let btn = UIButton(type: .system)
btn.setTitle("REL", for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 11, weight: .semibold)
btn.setTitleColor(.white, for: .normal)
btn.backgroundColor = color.withAlphaComponent(0.35)
btn.layer.cornerRadius = 4
btn.addTarget(self, action: #selector(toggleMode), for: .touchUpInside)
return btn
}()
init(label: String, color: UIColor, frame: CGRect) {
self.label = label
self.color = color
super.init(frame: frame)
backgroundColor = UIColor(white: 0.1, alpha: 1)
layer.borderColor = color.withAlphaComponent(0.3).cgColor
layer.borderWidth = 1
layer.cornerRadius = 6
clipsToBounds = true
topZone.backgroundColor = UIColor(white: 0.07, alpha: 1)
botZone.backgroundColor = UIColor(white: 0.07, alpha: 1)
addSubview(topZone)
addSubview(botZone)
gradLayer.colors = [UIColor.black.withAlphaComponent(0).cgColor, color.withAlphaComponent(0.45).cgColor]
gradLayer.startPoint = CGPoint(x: 0.5, y: 1)
gradLayer.endPoint = CGPoint(x: 0.5, y: 0)
fillView.layer.addSublayer(gradLayer)
addSubview(fillView)
line.backgroundColor = color
line.layer.shadowColor = color.cgColor
line.layer.shadowRadius = 4
line.layer.shadowOpacity = 0.8
line.layer.shadowOffset = .zero
addSubview(line)
dot.backgroundColor = color
dot.layer.cornerRadius = 5
dot.layer.shadowColor = color.cgColor
dot.layer.shadowRadius = 6
dot.layer.shadowOpacity = 1
dot.layer.shadowOffset = .zero
addSubview(dot)
nameLabel.textColor = UIColor(white: 0.4, alpha: 1)
nameLabel.text = label
addSubview(nameLabel)
addSubview(modeButton)
haptic.prepare()
}
required init?(coder: NSCoder) { fatalError() }
// MARK: - Layout
override func layoutSubviews() {
super.layoutSubviews()
let w = bounds.width
let h = bounds.height
topZone.frame = CGRect(x: 0, y: 0, width: w, height: padHeight)
botZone.frame = CGRect(x: 0, y: h - padHeight, width: w, height: padHeight)
line.frame.size = CGSize(width: w, height: 2)
dot.frame.size = CGSize(width: 10, height: 10)
dot.layer.cornerRadius = 5
nameLabel.frame = CGRect(x: 0, y: h - padHeight, width: w, height: padHeight)
modeButton.frame = CGRect(x: w / 2 - 22, y: padHeight / 2 - 12, width: 44, height: 24)
topZone.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }
botZone.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }
addDash(to: topZone, atBottom: true)
addDash(to: botZone, atBottom: false)
if !didInitialLayout {
didInitialLayout = true
showAtCurrentValue()
}
}
private func addDash(to zone: UIView, atBottom: Bool) {
let dash = CAShapeLayer()
let y: CGFloat = atBottom ? zone.bounds.height - 1 : 0
let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: y))
path.addLine(to: CGPoint(x: zone.bounds.width, y: y))
dash.path = path.cgPath
dash.strokeColor = UIColor(white: 0.25, alpha: 1).cgColor
dash.lineWidth = 1
dash.lineDashPattern = [6, 4]
zone.layer.addSublayer(dash)
}
// MARK: - Mode Toggle
@objc private func toggleMode() {
isRelative.toggle()
modeButton.setTitle(isRelative ? "REL" : "ABS", for: .normal)
modeButton.backgroundColor = isRelative
? color.withAlphaComponent(0.35)
: color.withAlphaComponent(0.15)
}
// MARK: - Touch
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let pt = touches.first?.location(in: self) else { return }
touchStartY = pt.y
valueAtTouchStart = currentValue
moveDot(to: pt)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard let pt = touches.first?.location(in: self) else { return }
moveDot(to: pt)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {}
private func moveDot(to pt: CGPoint) {
let activeTop = padHeight
let activeBottom = bounds.height - padHeight
let activeHeight = activeBottom - activeTop
let value: Float
if isRelative {
let deltaY = pt.y - touchStartY
let deltaPct = Float(-deltaY / activeHeight)
value = max(0, min(127, valueAtTouchStart + deltaPct * 127))
} else {
let clampedY = max(activeTop, min(activeBottom, pt.y))
value = max(0, min(127, Float((1 - (clampedY - activeTop) / activeHeight) * 127)))
}
currentValue = value
updateDisplay()
let intValue = Int(value)
if (intValue == 0 || intValue == 127) && intValue != lastHapticValue {
haptic.impactOccurred()
lastHapticValue = intValue
} else if intValue != 0 && intValue != 127 {
lastHapticValue = -1
}
print("[\(label)] val:\(intValue)")
}
private func showAtCurrentValue() { updateDisplay() }
private func updateDisplay() {
let activeTop = padHeight
let activeBottom = bounds.height - padHeight
let activeHeight = activeBottom - activeTop
let w = bounds.width
let displayY = activeBottom - CGFloat(currentValue / 127) * activeHeight
let fillFrame = CGRect(x: 0, y: displayY, width: w, height: activeBottom - displayY)
CATransaction.begin()
CATransaction.setDisableActions(true)
fillView.frame = fillFrame
gradLayer.frame = fillView.bounds
CATransaction.commit()
line.frame.origin = CGPoint(x: 0, y: displayY - 1)
dot.center = CGPoint(x: w / 2, y: displayY)
}
}
@@ -7,6 +7,9 @@ struct ModelPresetLoadToRemoteDevice: Codable {
let faders: [ModelFaderPayload]
let toggles: [ModelTogglePayload]
let transports: [ModelTransportPayload]
let focusFaders: [ModelFocusFaderPayload]
let focusToggles: [ModelFocusTogglePayload]
let focusFeedbacks: [ModelFocusFeedbackPayload]
var displayTitle: String {
bigTitle.isEmpty ? "No Title" : bigTitle
@@ -27,10 +30,42 @@ struct ModelTogglePayload: Codable {
let state: Bool
let destId: Int
let color: String
// The desktop's actual palette name (e.g. "Gray", "White", "Red") used
// instead of inferring from the resolved color's saturation, which can't
// reliably tell a true neutral gray apart from white (both ~0 saturation).
let colorName: String
}
struct ModelTransportPayload: Codable {
let uid: String
let label: String
let destId: Int
let color: String
}
// Channel Focus items are global (not per-preset) and shown via the dedicated
// Channel Focus mode toggle rather than destId-based routing.
struct ModelFocusFaderPayload: Codable {
let uid: String
let label: String
let value: Int
let color: String
}
struct ModelFocusTogglePayload: Codable {
let uid: String
let label: String
let state: Bool
let color: String
let colorName: String
let triggerMode: String
var isMomentary: Bool { triggerMode == "momentary" }
}
struct ModelFocusFeedbackPayload: Codable {
let uid: String
let label: String
let text: String
let color: String
}
@@ -0,0 +1,86 @@
import Foundation
// Discovers the desktop app's WebSocket server on the LAN via Bonjour/mDNS
// (matches the "_vcremote._tcp" service the desktop advertises while its
// tablet server is running) and auto-fills ipField/portField the user
// still taps Connect, this just saves typing the IP by hand.
extension ArrangeObjects: NetServiceBrowserDelegate, NetServiceDelegate {
func startBonjourDiscovery() {
print("[Bonjour] starting search for \(bonjourServiceType).local.")
bonjourBrowser.delegate = self
bonjourBrowser.searchForServices(ofType: "\(bonjourServiceType).", inDomain: "local.")
loggingView.log("Bonjour: searching for \(bonjourServiceType)", category: .local)
}
func stopBonjourDiscovery() {
print("[Bonjour] stopping search")
bonjourBrowser.stop()
discoveredServices.removeAll()
}
// MARK: - NetServiceBrowserDelegate
func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
print("[Bonjour] found service: \(service.name) domain=\(service.domain) moreComing=\(moreComing)")
discoveredServices.append(service)
service.delegate = self
service.resolve(withTimeout: 5)
}
func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) {
print("[Bonjour] service removed: \(service.name)")
discoveredServices.removeAll { $0 === service }
}
func netServiceBrowserDidStopSearch(_ browser: NetServiceBrowser) {
print("[Bonjour] search stopped")
}
func netServiceBrowser(_ browser: NetServiceBrowser, didNotSearch errorDict: [String: NSNumber]) {
print("[Bonjour] search failed to start: \(errorDict)")
}
// MARK: - NetServiceDelegate
func netServiceDidResolveAddress(_ sender: NetService) {
print("[Bonjour] resolving \(sender.name) — addresses: \(sender.addresses?.count ?? 0), rawPort=\(sender.port)")
guard let ip = Self.ipv4Address(from: sender) else {
print("[Bonjour] could not extract an IPv4 address from \(sender.name)")
return
}
print("[Bonjour] resolved \(sender.name) -> \(ip):\(sender.port)")
DispatchQueue.main.async {
self.ipField.text = ip
self.portField.text = "\(sender.port)"
self.loggingView.log("Bonjour found \(sender.name) at \(ip):\(sender.port)", category: .local)
self.showStatus("[ Bonjour found \(sender.name) \(self.statusTimestamp()) ]")
}
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
print("[Bonjour] failed to resolve \(sender.name): \(errorDict)")
loggingView.log("Bonjour: failed to resolve \(sender.name)\(errorDict)", category: .local)
}
// MARK: - Address parsing
private static func ipv4Address(from service: NetService) -> String? {
guard let addresses = service.addresses else { return nil }
for data in addresses {
if let ip = ipv4String(from: data) {
return ip
}
}
return nil
}
private static func ipv4String(from data: Data) -> String? {
data.withUnsafeBytes { rawPtr -> String? in
guard let sockaddrPtr = rawPtr.baseAddress?.assumingMemoryBound(to: sockaddr.self) else { return nil }
guard sockaddrPtr.pointee.sa_family == sa_family_t(AF_INET) else { return nil }
let sinPtr = rawPtr.baseAddress!.assumingMemoryBound(to: sockaddr_in.self)
var addr = sinPtr.pointee.sin_addr
var buffer = [CChar](repeating: 0, count: Int(INET_ADDRSTRLEN))
guard inet_ntop(AF_INET, &addr, &buffer, socklen_t(INET_ADDRSTRLEN)) != nil else { return nil }
return String(cString: buffer)
}
}
}
@@ -21,10 +21,34 @@ extension ArrangeObjects {
guard checkerIPAddressInput(ip: ip) else { return }
guard checkerPortInput(port: port) else { return }
guard let url = buildWebSocketURL(ip: ip, port: port) else { return }
UserDefaults.standard.set(ip, forKey: "last_ip")
UserDefaults.standard.set(port, forKey: "last_port")
self.connectWebSocket(url: url)
}
}
@objc func buttonTappedGridButton() {
let green = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
gridEnabled.toggle()
gridOverlay.isHidden = !gridEnabled
gridButton.backgroundColor = gridEnabled ? green : UIColor(white: 0.15, alpha: 1)
gridButton.setTitleColor(gridEnabled ? .black : .lightGray, for: .normal)
}
@objc func tabletIdFieldChanged() {
guard let text = tabletIdField.text, let idVal = Int(text), idVal > 0 else { return }
myTabletId = idVal
if let preset = currentPreset {
buildUIFromPreset(preset)
}
}
@objc func buttonTappedClearLayouts() {
UserDefaults.standard.removeObject(forKey: "layout_\(self.presetUuid)")
loggingView.log("cleared layout for \(self.presetUuid)", category: .local)
if let preset = currentPreset { buildUIFromPreset(preset) }
}
@objc func buttonTappedShowLogging() {
let visible = (self.logWidget.isHidden == false)
self.logWidget.isHidden = visible
@@ -39,17 +63,69 @@ extension ArrangeObjects {
loggingView.log("auto switch \(autoSwitchEnabled ? "enabled" : "disabled")", category: .local)
}
@objc func buttonTappedChannelFocus() {
guard currentPreset != nil else {
loggingView.log("channel focus: no preset received yet", category: .local)
return
}
isChannelFocusMode.toggle()
let green = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
channelFocusButton.backgroundColor = isChannelFocusMode ? green : UIColor(white: 0.15, alpha: 1)
channelFocusButton.setTitleColor(isChannelFocusMode ? .black : .lightGray, for: .normal)
if isChannelFocusMode {
buildChannelFocusUI()
} else if let preset = currentPreset {
buildUIFromPreset(preset)
}
loggingView.log("channel focus \(isChannelFocusMode ? "enabled" : "disabled")", category: .local)
}
@objc func buttonTappedArrangeButton() {
self.isLocked = false
self.updateUIArrangeLockButtons()
self.widgets.values.forEach { $0.setArrangeMode(true) }
isLocked.toggle()
updateUIArrangeLockButtons()
if isLocked {
saveLayout()
arrangeSessionLayoutKey = nil
arrangeSessionLayoutSnapshot = nil
widgets.values.forEach { $0.setArrangeMode(false) }
} else {
if let key = currentLayoutKey {
arrangeSessionLayoutKey = key
arrangeSessionLayoutSnapshot = UserDefaults.standard.dictionary(forKey: key) ?? [:]
}
widgets.values.forEach { $0.setArrangeMode(true) }
}
}
@objc func buttonTappedLockButton() {
self.isLocked = true
self.updateUIArrangeLockButtons()
self.saveLayout()
self.widgets.values.forEach { $0.setArrangeMode(false) }
// Reverts to how things were the moment Arrange mode was entered
// undoes drags/resizes/alignment changes/hides made this session, even
// though most of those already auto-saved along the way.
@objc func buttonTappedCancelChanges() {
guard let key = arrangeSessionLayoutKey else { return }
UserDefaults.standard.set(arrangeSessionLayoutSnapshot ?? [:], forKey: key)
// Hiding a regular (non-Channel-Focus) widget tells the desktop to
// uncheck its Remote assignment and saves that to the preset file
// resend every regular widget's original destId so the desktop's
// state matches again. Channel Focus uids aren't tracked server-side
// for this message, so this is a no-op for those either way.
if let preset = currentPreset {
for f in preset.faders { wsClientSendJSON(["event": "widget_visibility", "uid": f.uid, "dest_id": f.destId]) }
for t in preset.toggles { wsClientSendJSON(["event": "widget_visibility", "uid": t.uid, "dest_id": t.destId]) }
for t in preset.transports { wsClientSendJSON(["event": "widget_visibility", "uid": t.uid, "dest_id": t.destId]) }
}
arrangeSessionLayoutKey = nil
arrangeSessionLayoutSnapshot = nil
isLocked = true
updateUIArrangeLockButtons()
if isChannelFocusMode {
buildChannelFocusUI()
} else if let preset = currentPreset {
buildUIFromPreset(preset)
}
showStatus("[ Cancelled \(self.statusTimestamp()) ]")
}
}
@@ -1,19 +1,109 @@
import UIKit
extension ArrangeObjects: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
}
}
extension ArrangeObjects {
// MARK: - Gestures
func addDragGesture(to view: UIView) {
let pan = UIPanGestureRecognizer(target: self, action: #selector(handleGesturePan(_:)))
pan.cancelsTouchesInView = false
view.addGestureRecognizer(pan)
}
@objc func handleGesturePan(_ gesture: UIPanGestureRecognizer) {
guard self.isLocked == false, let view = gesture.view else { return }
let translation = gesture.translation(in: self.containerView)
view.center = CGPoint(x: view.center.x + translation.x, y: view.center.y + translation.y)
gesture.setTranslation(.zero, in: self.containerView)
if gesture.state == .began {
dragStartOrigin = view.frame.origin
lastSnappedOrigin = view.frame.origin
if gridEnabled { snapHaptic.prepare() }
}
let total = gesture.translation(in: self.containerView)
let rawOrigin = CGPoint(x: dragStartOrigin.x + total.x, y: dragStartOrigin.y + total.y)
if gridEnabled {
let snapped = CGPoint(x: snapValue(rawOrigin.x), y: snapValue(rawOrigin.y))
view.frame.origin = snapped
if snapped != lastSnappedOrigin {
snapHaptic.impactOccurred()
lastSnappedOrigin = snapped
}
} else {
view.frame.origin = rawOrigin
}
}
func snapFrame(_ frame: CGRect) -> CGRect {
guard gridEnabled else { return frame }
let nearestLeft = snapValue(frame.minX)
let nearestRight = snapValue(frame.maxX) - frame.width
let newX = abs(nearestLeft - frame.minX) <= abs(nearestRight - frame.minX) ? nearestLeft : nearestRight
let nearestTop = snapValue(frame.minY)
let nearestBottom = snapValue(frame.maxY) - frame.height
let newY = abs(nearestTop - frame.minY) <= abs(nearestBottom - frame.minY) ? nearestTop : nearestBottom
return CGRect(x: newX, y: newY, width: frame.width, height: frame.height)
}
func snapValue(_ value: CGFloat) -> CGFloat {
(value / gridSize).rounded() * gridSize
}
func addPinchGesture(to view: UIView) {
let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handleGesturePinch(_:)))
pinch.delegate = self
view.addGestureRecognizer(pinch)
}
@objc func handleGesturePinch(_ gesture: UIPinchGestureRecognizer) {
guard self.isLocked == false, let widget = gesture.view else { return }
switch gesture.state {
case .began:
pinchStartSize = widget.bounds.size
case .changed:
let scale = max(gesture.scale, 75 / min(pinchStartSize.width, pinchStartSize.height))
let center = widget.center
widget.bounds.size = CGSize(width: pinchStartSize.width * scale, height: pinchStartSize.height * scale)
widget.center = center
case .ended, .cancelled:
widget.frame.origin = CGPoint(x: snapValue(widget.frame.origin.x), y: snapValue(widget.frame.origin.y))
saveLayout()
default:
break
}
}
// FocusFeedbackWidget pinch: adjusts font size directly rather than an
// independent bounds size, so the frame is always a tight fit around the
// text (no baked-in minimum box) and aspect ratio can't drift from it.
func addFeedbackPinchGesture(to widget: FocusFeedbackWidget) {
let pinch = UIPinchGestureRecognizer(target: self, action: #selector(handleFeedbackPinch(_:)))
pinch.delegate = self
widget.addGestureRecognizer(pinch)
}
@objc func handleFeedbackPinch(_ gesture: UIPinchGestureRecognizer) {
guard self.isLocked == false, let widget = gesture.view as? FocusFeedbackWidget else { return }
switch gesture.state {
case .began:
pinchStartFontSize = widget.fontSize
case .changed:
widget.setFontSize(pinchStartFontSize * gesture.scale)
case .ended, .cancelled:
widget.frame.origin = CGPoint(x: snapValue(widget.frame.origin.x), y: snapValue(widget.frame.origin.y))
saveLayout()
default:
break
}
}
func addContextMenuGesture(to view: UIView) {
@@ -31,6 +121,56 @@ extension ArrangeObjects {
let alert = UIAlertController(title: widget.displayName, message: nil, preferredStyle: .actionSheet)
alert.overrideUserInterfaceStyle = .dark
// Channel Focus fader only: longer throw = finer touch resolution,
// since the active drag zone grows with the widget's height while the
// top/bottom padding stays fixed (see FocusFaderWidget.padHeight).
if widget is FocusFaderWidget {
alert.addAction(UIAlertAction(title: "50% Longer", style: .default) { [weak self] _ in
self?.scaleFocusFaderLength(widget, multiplier: 1.5)
})
alert.addAction(UIAlertAction(title: "70% Longer", style: .default) { [weak self] _ in
self?.scaleFocusFaderLength(widget, multiplier: 1.7)
})
alert.addAction(UIAlertAction(title: "Reset Length", style: .default) { [weak self] _ in
self?.scaleFocusFaderLength(widget, multiplier: 1.0)
})
}
if let alignable = widget as? TextAlignableWidget {
let current = alignable.textAlignmentName
func alignTitle(_ label: String, _ value: String) -> String {
value == current ? "\(label)" : label
}
alert.addAction(UIAlertAction(title: alignTitle("Align Left", "left"), style: .default) { [weak self] _ in
alignable.textAlignmentName = "left"
self?.saveLayout()
})
alert.addAction(UIAlertAction(title: alignTitle("Align Center", "center"), style: .default) { [weak self] _ in
alignable.textAlignmentName = "center"
self?.saveLayout()
})
alert.addAction(UIAlertAction(title: alignTitle("Align Right", "right"), style: .default) { [weak self] _ in
alignable.textAlignmentName = "right"
self?.saveLayout()
})
}
// FocusFeedbackWidget's box size is a direct function of its font
// size (see resizeToFitText) pinch changes fontSize, so resetting
// fontSize back to defaultFontSize is what "original size" means here.
if let feedbackWidget = widget as? FocusFeedbackWidget {
alert.addAction(UIAlertAction(title: "Reset Size", style: .default) { [weak self] _ in
feedbackWidget.setFontSize(FocusFeedbackWidget.defaultFontSize)
self?.saveLayout()
})
}
if let colorable = widget as? TextColorableWidget {
alert.addAction(UIAlertAction(title: "Text Color", style: .default) { [weak self] _ in
self?.presentTextColorPicker(for: colorable, widget: widget)
})
}
alert.addAction(UIAlertAction(title: "Hide", style: .destructive) { [weak self] _ in
self?.hideWidget(widget)
})
@@ -44,6 +184,28 @@ extension ArrangeObjects {
self.present(alert, animated: true)
}
func presentTextColorPicker(for colorable: TextColorableWidget, widget: BaseWidget) {
let alert = UIAlertController(title: "Text Color", message: nil, preferredStyle: .actionSheet)
alert.overrideUserInterfaceStyle = .dark
let current = colorable.textColorName
for entry in NamedColorPalette.entries {
let title = entry.name == current ? "\(entry.name)" : entry.name
alert.addAction(UIAlertAction(title: title, style: .default) { [weak self] _ in
colorable.textColorName = entry.name
self?.saveLayout()
})
}
alert.addAction(UIAlertAction(title: "Cancel", style: .cancel))
if let popover = alert.popoverPresentationController {
popover.sourceView = widget
popover.sourceRect = widget.bounds
}
self.present(alert, animated: true)
}
func hideWidget(_ widget: BaseWidget) {
widget.removeFromSuperview()
self.widgets.removeValue(forKey: widget.uid)
@@ -51,4 +213,17 @@ extension ArrangeObjects {
self.wsClientSendJSON(["event": "widget_visibility", "uid": widget.uid, "dest_id": 0])
}
// Scales only the height, anchored to the fader's bottom edge (its "zero"
// reference point) width and position stay put. multiplier is relative
// to the widget's default size, not its current size, so picking the same
// option twice is idempotent rather than compounding.
func scaleFocusFaderLength(_ widget: BaseWidget, multiplier: CGFloat) {
let bottomY = widget.frame.maxY
let newHeight = FocusFaderWidget.size.height * multiplier
widget.frame.size.width = FocusFaderWidget.size.width
widget.frame.size.height = newHeight
widget.frame.origin.y = bottomY - newHeight
self.saveLayout()
}
}
@@ -19,13 +19,18 @@ extension ArrangeObjects {
self.presetUuid = preset.presetUuid
self.widgets = [:]
self.feedbackWidgets = []
self.containerView.subviews.forEach { if $0 !== self.logWidget { $0.removeFromSuperview() } }
self.containerView.subviews.forEach { if $0 !== self.logWidget && $0 !== self.gridOverlay { $0.removeFromSuperview() } }
self.containerView.sendSubviewToBack(self.gridOverlay)
self.presetLabel.text = preset.presetName
let titleUid = "title-big"
let titleFrame = savedFrame(uid: titleUid, in: savedLayout) ?? CGRect(x: 16, y: 16, width: 300, height: 44)
let titleFrame = savedFrame(uid: titleUid, in: savedLayout) ?? CGRect(x: 16, y: 90, width: 300, height: 44)
let titleWidget = TitleWidget(uid: titleUid, text: preset.displayTitle, frame: titleFrame)
if let align = savedAlignment(uid: titleUid, in: savedLayout) {
titleWidget.textAlignmentName = align
}
self.addDragGesture(to: titleWidget)
self.addContextMenuGesture(to: titleWidget)
self.containerView.addSubview(titleWidget)
self.widgets[titleUid] = titleWidget
@@ -41,6 +46,7 @@ extension ArrangeObjects {
frame: frame)
w.onSend = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) }
self.addDragGesture(to: w)
// self.addPinchGesture(to: w)
self.addContextMenuGesture(to: w)
self.containerView.addSubview(w)
self.widgets[f.uid] = w
@@ -56,6 +62,7 @@ extension ArrangeObjects {
frame: frame)
w.onSend = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) }
self.addDragGesture(to: w)
self.addPinchGesture(to: w)
self.addContextMenuGesture(to: w)
self.containerView.addSubview(w)
self.widgets[t.uid] = w
@@ -66,9 +73,11 @@ extension ArrangeObjects {
let frame = savedFrame(uid: t.uid, in: savedLayout) ?? TransportWidget.defaultFrame(idx: squareIdx)
let w = TransportWidget(uid: t.uid,
label: t.label,
color: UIColor.fromHex(t.color),
frame: frame)
w.onSend = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) }
self.addDragGesture(to: w)
self.addPinchGesture(to: w)
self.addContextMenuGesture(to: w)
self.containerView.addSubview(w)
self.widgets[t.uid] = w
@@ -80,24 +89,110 @@ extension ArrangeObjects {
self.showStatus("[ \(preset.presetName) \(self.statusTimestamp()) ]")
}
// Channel Focus: global, static controls shown instead of the current preset's
// widgets, independent of destId routing and unaffected by preset auto-switch.
func buildChannelFocusUI() {
guard let preset = currentPreset else { return }
let layoutKey = "layout_channel_focus"
let savedLayout = UserDefaults.standard.dictionary(forKey: layoutKey) ?? [:]
self.widgets = [:]
self.feedbackWidgets = []
self.containerView.subviews.forEach { if $0 !== self.logWidget && $0 !== self.gridOverlay { $0.removeFromSuperview() } }
self.containerView.sendSubviewToBack(self.gridOverlay)
self.presetLabel.text = "Channel Focus"
var faderIdx = 0
var squareIdx = 0
for f in preset.focusFaders {
let faderFrame = savedFrame(uid: f.uid, in: savedLayout) ?? FocusFaderWidget.defaultFrame(idx: faderIdx)
let faderWidget = FocusFaderWidget(uid: f.uid,
label: f.label,
value: f.value,
color: UIColor.fromHex(f.color),
frame: faderFrame)
faderWidget.onSendFloat = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control_f", "uid": uid, "value": val]) }
self.addDragGesture(to: faderWidget)
self.addContextMenuGesture(to: faderWidget)
self.containerView.addSubview(faderWidget)
self.widgets[f.uid] = faderWidget
faderIdx += 1
}
for t in preset.focusToggles {
let frame = savedFrame(uid: t.uid, in: savedLayout) ?? FocusToggleWidget.defaultFrame(idx: squareIdx)
let w = FocusToggleWidget(uid: t.uid,
label: t.label,
isOn: t.state,
color: UIColor.fromHex(t.color),
colorName: t.colorName,
isMomentary: t.isMomentary,
frame: frame)
w.onSend = { [weak self] uid, val in self?.wsClientSendJSON(["event": "control", "uid": uid, "value": val]) }
self.addDragGesture(to: w)
self.addPinchGesture(to: w)
self.addContextMenuGesture(to: w)
self.containerView.addSubview(w)
self.widgets[t.uid] = w
squareIdx += 1
}
var feedbackIdx = 0
for fb in preset.focusFeedbacks {
let frame = savedFrame(uid: fb.uid, in: savedLayout) ?? FocusFeedbackWidget.defaultFrame(idx: feedbackIdx, text: fb.text)
let w = FocusFeedbackWidget(uid: fb.uid,
label: fb.label,
text: fb.text,
frame: frame)
if let align = savedAlignment(uid: fb.uid, in: savedLayout) {
w.textAlignmentName = align
}
if let colorName = savedTextColorName(uid: fb.uid, in: savedLayout) {
w.textColorName = colorName
}
self.addDragGesture(to: w)
self.addFeedbackPinchGesture(to: w)
self.addContextMenuGesture(to: w)
self.containerView.addSubview(w)
self.widgets[fb.uid] = w
feedbackIdx += 1
}
self.isLocked = savedLayout.count > 0
self.updateUIArrangeLockButtons()
self.showStatus("[ Channel Focus \(self.statusTimestamp()) ]")
}
func saveLayout() {
guard self.presetUuid.isEmpty == false else {
guard let key = currentLayoutKey else {
self.loggingView.log("saveLayout: aborted — presetUuid is empty", category: .local)
return
}
saveLayoutWidgets(key: key)
}
private func saveLayoutWidgets(key: String) {
var layoutDict: [String: Any] = [:]
for (uid, w) in self.widgets {
let f = w.frame
layoutDict[uid] = [
var entry: [String: Any] = [
"x": Double(f.origin.x),
"y": Double(f.origin.y),
"w": Double(f.width),
"h": Double(f.height)
]
if let alignable = w as? TextAlignableWidget {
entry["align"] = alignable.textAlignmentName
}
UserDefaults.standard.set(layoutDict, forKey: "layout_\(self.presetUuid)")
if let colorable = w as? TextColorableWidget {
entry["textColor"] = colorable.textColorName
}
layoutDict[uid] = entry
}
UserDefaults.standard.set(layoutDict, forKey: key)
self.showStatus("[ Saved \(self.statusTimestamp()) ]")
self.loggingView.log("✓ saveLayout: \(layoutDict.count) widgets saved key=layout_\(self.presetUuid)", category: .local)
self.loggingView.log("✓ saveLayout: \(layoutDict.count) widgets saved key=\(key)", category: .local)
if let data = try? JSONSerialization.data(withJSONObject: layoutDict),
let jsonStr = String(data: data, encoding: .utf8) {
self.loggingView.log(jsonStr, category: .local)
@@ -112,4 +207,14 @@ extension ArrangeObjects {
return CGRect(x: x, y: y, width: w, height: h)
}
func savedAlignment(uid: String, in savedLayout: [String: Any]) -> String? {
guard let saved = savedLayout[uid] as? [String: Any] else { return nil }
return saved["align"] as? String
}
func savedTextColorName(uid: String, in savedLayout: [String: Any]) -> String? {
guard let saved = savedLayout[uid] as? [String: Any] else { return nil }
return saved["textColor"] as? String
}
}
@@ -29,21 +29,30 @@ extension ArrangeObjects {
self.connectButton.backgroundColor = red
self.connectButton.setTitleColor(.white, for: .normal)
self.connectButton.isEnabled = true
stopBonjourDiscovery()
case .disconnected:
self.statusLabel.text = "disconnected"
self.connectButton.setTitle("Reconnect", for: .normal)
self.connectButton.backgroundColor = green
self.connectButton.setTitleColor(.black, for: .normal)
self.connectButton.isEnabled = true
startBonjourDiscovery()
}
}
func updateUIArrangeLockButtons() {
let green = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
self.lockButton.backgroundColor = self.isLocked ? green : UIColor(white: 0.15, alpha: 1)
self.lockButton.setTitleColor(self.isLocked ? .black : .lightGray, for: .normal)
self.arrangeButton.backgroundColor = self.isLocked ? UIColor(white: 0.15, alpha: 1) : green
self.arrangeButton.setTitleColor(self.isLocked ? .lightGray : .black, for: .normal)
arrangeButton.setTitle(isLocked ? "Arrange" : "Lock / Save", for: .normal)
arrangeButton.backgroundColor = isLocked ? UIColor(white: 0.15, alpha: 1) : green
arrangeButton.setTitleColor(isLocked ? .lightGray : .black, for: .normal)
gridButton.isHidden = isLocked
cancelChangesButton.isHidden = isLocked
if isLocked {
gridEnabled = false
gridOverlay.isHidden = true
gridButton.backgroundColor = UIColor(white: 0.15, alpha: 1)
gridButton.setTitleColor(.lightGray, for: .normal)
}
}
func updateUIFromDawState(_ json: [String: Any]) {
@@ -64,6 +73,10 @@ extension ArrangeObjects {
self.widgets[uid]?.update(value: value)
}
func updateWidgetLabel(uid: String, label: String) {
self.widgets[uid]?.applyLabel(label)
}
func showStatus(_ message: String) {
DispatchQueue.main.async {
self.statusToken += 1
@@ -66,6 +66,11 @@ extension ArrangeObjects {
self.loggingView.log(jsonStr, category: .local)
}
DispatchQueue.main.async {
self.currentPreset = preset
guard self.isChannelFocusMode == false else {
self.loggingView.log("preset event cached but not rendered — channel focus is active", category: .local)
return
}
let isNewPreset = preset.presetUuid != self.presetUuid
if self.isLocked == false && isNewPreset {
let alert = UIAlertController(
@@ -98,6 +103,18 @@ extension ArrangeObjects {
if let uid = json["uid"] as? String, let value = json["value"] as? Int {
DispatchQueue.main.async { self.updateWidget(uid: uid, value: value) }
}
case "widget_label":
if let uid = json["uid"] as? String, let label = json["label"] as? String {
DispatchQueue.main.async { self.updateWidgetLabel(uid: uid, label: label) }
}
case "widget_update_f":
if let uid = json["uid"] as? String, let value = json["value"] as? Double {
DispatchQueue.main.async { self.widgets[uid]?.applyFloatValue(value) }
}
case "widget_feedback":
if let uid = json["uid"] as? String, let text = json["text"] as? String {
DispatchQueue.main.async { self.widgets[uid]?.applyFeedbackText(text) }
}
case "daw_state":
DispatchQueue.main.async { self.updateUIFromDawState(json) }
default:
@@ -7,6 +7,13 @@ class ArrangeObjects: UIViewController {
var urlSession: URLSession?
// MARK: - Bonjour Discovery
// Auto-fills ipField/portField when the desktop app's service is found on
// the LAN user still taps Connect, this just saves typing the IP.
let bonjourServiceType = "_vcremote._tcp"
lazy var bonjourBrowser: NetServiceBrowser = NetServiceBrowser()
var discoveredServices: [NetService] = []
// MARK: - Connection State
enum ConnectionState {
case notStarted, connecting, connected, disconnected
@@ -14,10 +21,25 @@ class ArrangeObjects: UIViewController {
var connectionState: ConnectionState = .notStarted
// MARK: - State
var currentPreset: ModelPresetLoadToRemoteDevice? = nil
var presetName: String = ""
var presetUuid: String = ""
var isLocked: Bool = true
var autoSwitchEnabled: Bool = true
var isChannelFocusMode: Bool = false
// MARK: - Arrange Session (Cancel Changes)
// Captured the moment Arrange mode is entered, so "Cancel Changes" can
// restore the layout exactly as it was, undoing any drags/resizes/hides
// made during the session (most of which auto-save immediately).
var arrangeSessionLayoutKey: String?
var arrangeSessionLayoutSnapshot: [String: Any]?
var currentLayoutKey: String? {
if isChannelFocusMode { return "layout_channel_focus" }
guard presetUuid.isEmpty == false else { return nil }
return "layout_\(presetUuid)"
}
var myTabletId: Int {
get {
let stored = UserDefaults.standard.integer(forKey: "tablet_id")
@@ -32,11 +54,25 @@ class ArrangeObjects: UIViewController {
// MARK: - Layout
var layout: [String: CGRect] = [:]
var pinchStartSize: CGSize = .zero
var pinchStartFontSize: CGFloat = FocusFeedbackWidget.defaultFontSize
var dragStartOrigin: CGPoint = .zero
var lastSnappedOrigin: CGPoint = .zero
lazy var snapHaptic = UIImpactFeedbackGenerator(style: .light)
var gridEnabled: Bool = false
let gridSize: CGFloat = 25
lazy var gridOverlay: GridView = {
let v = GridView(frame: .zero)
v.gridSize = self.gridSize
v.translatesAutoresizingMaskIntoConstraints = false
return v
}()
let containerView: UIView = {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.backgroundColor = UIColor(white: 0.1, alpha: 1)
view.backgroundColor = .black
return view
}()
@@ -53,6 +89,21 @@ class ArrangeObjects: UIViewController {
return btn
}()
lazy var gridButton: UIButton = {
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
btn.setTitle("Grid", for: .normal)
btn.setTitleColor(.lightGray, for: .normal)
btn.backgroundColor = UIColor(white: 0.15, alpha: 1)
btn.layer.borderWidth = 1
btn.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
btn.layer.cornerRadius = 4
btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
btn.addTarget(self, action: #selector(self.buttonTappedGridButton), for: .touchUpInside)
btn.isHidden = true
return btn
}()
lazy var arrangeButton: UIButton = {
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
@@ -67,6 +118,21 @@ class ArrangeObjects: UIViewController {
return btn
}()
lazy var cancelChangesButton: UIButton = {
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
btn.setTitle("Cancel Changes", for: .normal)
btn.setTitleColor(.lightGray, for: .normal)
btn.backgroundColor = UIColor(white: 0.15, alpha: 1)
btn.layer.borderWidth = 1
btn.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
btn.layer.cornerRadius = 4
btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
btn.addTarget(self, action: #selector(self.buttonTappedCancelChanges), for: .touchUpInside)
btn.isHidden = true
return btn
}()
lazy var autoSwitchButton: UIButton = {
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
@@ -79,18 +145,21 @@ class ArrangeObjects: UIViewController {
return btn
}()
lazy var lockButton: UIButton = {
lazy var channelFocusButton: UIButton = {
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
btn.setTitle("Lock / Save", for: .normal)
btn.setTitleColor(.black, for: .normal)
btn.backgroundColor = UIColor(red: 0, green: 0.9, blue: 0.46, alpha: 1)
btn.setTitle("Channel Focus", for: .normal)
btn.setTitleColor(.lightGray, for: .normal)
btn.backgroundColor = UIColor(white: 0.15, alpha: 1)
btn.layer.borderWidth = 1
btn.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
btn.layer.cornerRadius = 4
btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
btn.addTarget(self, action: #selector(self.buttonTappedLockButton), for: .touchUpInside)
btn.addTarget(self, action: #selector(self.buttonTappedChannelFocus), for: .touchUpInside)
return btn
}()
// MARK: - Connection Fields
let ipField: UITextField = {
let tf = UITextField()
@@ -157,6 +226,20 @@ class ArrangeObjects: UIViewController {
return w
}()
lazy var clearLayoutsButton: UIButton = {
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
btn.setTitle("Clear Layouts", for: .normal)
btn.setTitleColor(.lightGray, for: .normal)
btn.backgroundColor = UIColor(white: 0.15, alpha: 1)
btn.layer.borderWidth = 1
btn.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
btn.layer.cornerRadius = 4
btn.contentEdgeInsets = UIEdgeInsets(top: 6, left: 14, bottom: 6, right: 14)
btn.addTarget(self, action: #selector(self.buttonTappedClearLayouts), for: .touchUpInside)
return btn
}()
lazy var showLoggingButton: UIButton = {
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
@@ -5,7 +5,7 @@ extension ArrangeObjects {
func setupUI() {
DispatchQueue.main.async {
self.view.backgroundColor = UIColor(white: 0.1, alpha: 1)
self.view.backgroundColor = .black
// containerView
self.view.addSubview(self.containerView)
@@ -14,20 +14,31 @@ extension ArrangeObjects {
self.containerView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: 0).isActive = true
self.containerView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor, constant: 0).isActive = true
// statusLabel
self.view.addSubview(self.statusLabel)
self.statusLabel.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.statusLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 8).isActive = true
// presetLabel
self.view.addSubview(self.presetLabel)
self.presetLabel.topAnchor.constraint(equalTo: self.statusLabel.bottomAnchor, constant: 2).isActive = true
self.presetLabel.leadingAnchor.constraint(equalTo: self.view.leadingAnchor, constant: 8).isActive = true
// grid overlay pinned to containerView, behind all widgets
self.containerView.addSubview(self.gridOverlay)
self.gridOverlay.topAnchor.constraint(equalTo: self.containerView.topAnchor).isActive = true
self.gridOverlay.leadingAnchor.constraint(equalTo: self.containerView.leadingAnchor).isActive = true
self.gridOverlay.trailingAnchor.constraint(equalTo: self.containerView.trailingAnchor).isActive = true
self.gridOverlay.bottomAnchor.constraint(equalTo: self.containerView.bottomAnchor).isActive = true
self.gridOverlay.isHidden = true
// toolbar buttons (top-right)
self.view.addSubview(self.clearLayoutsButton)
self.clearLayoutsButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.clearLayoutsButton.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -8).isActive = true
// status and preset label stacked below Clear Layouts
self.view.addSubview(self.statusLabel)
self.statusLabel.topAnchor.constraint(equalTo: self.clearLayoutsButton.bottomAnchor, constant: 6).isActive = true
self.statusLabel.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -8).isActive = true
self.view.addSubview(self.presetLabel)
self.presetLabel.topAnchor.constraint(equalTo: self.statusLabel.bottomAnchor, constant: 2).isActive = true
self.presetLabel.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -8).isActive = true
self.view.addSubview(self.showLoggingButton)
self.showLoggingButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.showLoggingButton.trailingAnchor.constraint(equalTo: self.view.trailingAnchor, constant: -8).isActive = true
self.showLoggingButton.trailingAnchor.constraint(equalTo: self.clearLayoutsButton.leadingAnchor, constant: -6).isActive = true
self.view.addSubview(self.toolbarStatusLabel)
self.toolbarStatusLabel.topAnchor.constraint(equalTo: self.showLoggingButton.bottomAnchor, constant: 4).isActive = true
@@ -43,6 +54,9 @@ extension ArrangeObjects {
self.tabletIdField.widthAnchor.constraint(equalToConstant: 40).isActive = true
self.tabletIdField.heightAnchor.constraint(equalTo: self.connectButton.heightAnchor).isActive = true
self.tabletIdField.text = "\(self.myTabletId)"
if let lastIp = UserDefaults.standard.string(forKey: "last_ip") { self.ipField.text = lastIp }
if let lastPort = UserDefaults.standard.string(forKey: "last_port") { self.portField.text = lastPort }
self.tabletIdField.addTarget(self, action: #selector(self.tabletIdFieldChanged), for: .editingChanged)
self.view.addSubview(self.portField)
self.portField.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
@@ -56,17 +70,25 @@ extension ArrangeObjects {
self.ipField.widthAnchor.constraint(equalToConstant: 120).isActive = true
self.ipField.heightAnchor.constraint(equalTo: self.connectButton.heightAnchor).isActive = true
self.view.addSubview(self.lockButton)
self.lockButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.lockButton.trailingAnchor.constraint(equalTo: self.ipField.leadingAnchor, constant: -6).isActive = true
self.view.addSubview(self.autoSwitchButton)
self.autoSwitchButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.autoSwitchButton.trailingAnchor.constraint(equalTo: self.lockButton.leadingAnchor, constant: -6).isActive = true
self.autoSwitchButton.trailingAnchor.constraint(equalTo: self.ipField.leadingAnchor, constant: -6).isActive = true
self.view.addSubview(self.gridButton)
self.gridButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.view.addSubview(self.cancelChangesButton)
self.cancelChangesButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.view.addSubview(self.arrangeButton)
self.arrangeButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.arrangeButton.trailingAnchor.constraint(equalTo: self.autoSwitchButton.leadingAnchor, constant: -6).isActive = true
self.cancelChangesButton.trailingAnchor.constraint(equalTo: self.arrangeButton.leadingAnchor, constant: -6).isActive = true
self.gridButton.trailingAnchor.constraint(equalTo: self.cancelChangesButton.leadingAnchor, constant: -6).isActive = true
self.view.addSubview(self.channelFocusButton)
self.channelFocusButton.topAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.topAnchor, constant: 8).isActive = true
self.channelFocusButton.trailingAnchor.constraint(equalTo: self.gridButton.leadingAnchor, constant: -6).isActive = true
self.containerView.addSubview(self.logWidget)
self.addDragGesture(to: self.logWidget)
@@ -12,6 +12,7 @@ class ArrangeView: ArrangeObjects {
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
startBonjourDiscovery()
}
}
@@ -28,8 +28,14 @@ class BaseWidget: UIView {
}
func applyValue(_ value: Int) {}
func applyFloatValue(_ value: Double) {}
func applyFeedbackText(_ text: String) {}
func setArrangeMode(_ enabled: Bool) {}
func applyLabel(_ label: String) {
self.displayName = label
}
// MARK: - Activity Ring
private func setupActivityRingGesture() {
@@ -68,6 +74,40 @@ class BaseWidget: UIView {
}
// Conformed to by label-only widgets (TitleWidget, FocusFeedbackWidget) whose
// text can be left/center/right aligned within their own box via the Arrange
// mode long-press menu.
protocol TextAlignableWidget: AnyObject {
var textAlignmentName: String { get set }
}
// Conformed to by widgets whose text color can be picked from the app's
// named palette via the Arrange mode long-press menu.
protocol TextColorableWidget: AnyObject {
var textColorName: String { get set }
}
// Mirrors the desktop's palette.py PALETTE list exactly, so a color picked
// here means the same thing it would on the desktop.
enum NamedColorPalette {
static let entries: [(name: String, hex: String)] = [
("Red", "#e53935"),
("Orange", "#fb8c00"),
("Yellow", "#fdd835"),
("Green", "#43a047"),
("Teal", "#00897b"),
("Blue", "#1e88e5"),
("Purple", "#8e24aa"),
("Pink", "#e91e63"),
("White", "#f5f5f5"),
("Gray", "#4a5568"),
]
static func hex(for name: String) -> String {
entries.first(where: { $0.name == name })?.hex ?? "#f5f5f5"
}
}
extension BaseWidget: UIGestureRecognizerDelegate {
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return true
@@ -87,4 +127,14 @@ extension UIColor {
alpha: CGFloat((int >> 24) & 0xFF) / 255
)
}
/// Scales each RGB channel toward black by `percentage` (0-1). Used for
/// toggle off-states a dim, tinted version of the widget's own assigned
/// color instead of a flat generic gray.
func darkened(by percentage: CGFloat) -> UIColor {
var r: CGFloat = 0, g: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
guard self.getRed(&r, green: &g, blue: &b, alpha: &a) else { return self }
let factor = 1 - percentage
return UIColor(red: r * factor, green: g * factor, blue: b * factor, alpha: a)
}
}
@@ -2,35 +2,62 @@ import UIKit
class FaderWidget: BaseWidget {
static let size = CGSize(width: 140, height: 360)
static let size = CGSize(width: 98, height: 540)
static func defaultFrame(idx: Int) -> CGRect {
let col = idx % 3
let row = idx / 3
return CGRect(x: 16 + CGFloat(col) * 170, y: 48 + CGFloat(row) * 380, width: size.width, height: size.height)
let col = idx % 4
let row = idx / 4
return CGRect(x: 16 + CGFloat(col) * 118, y: 90 + CGFloat(row) * 560, width: size.width, height: size.height)
}
// MARK: - Properties
let color: UIColor
private var sliderWidthConstraint: NSLayoutConstraint?
private let padHeight: CGFloat = 90
private var arrangeMode = false
private var isRelative = true
private var currentValue: Float = 0
private var touchStartY: CGFloat = 0
private var valueAtTouchStart: Float = 0
private var didInitialLayout = false
private var lastHapticValue: Int = -1
private var lastSentValue: Int = -1
private let haptic = UIImpactFeedbackGenerator(style: .medium)
// MARK: - Subviews
lazy var nameLabel: UILabel = {
private let topZone = UIView()
private let botZone = UIView()
private let fillView = UIView()
private let gradLayer = CAGradientLayer()
private let line = UIView()
private let dot = UIView()
private let arrangeCover = UIView() // clear overlay that blocks touches in arrange mode
private let nameLabel: UILabel = {
let lbl = UILabel()
lbl.translatesAutoresizingMaskIntoConstraints = false
lbl.textColor = UIColor(white: 0.53, alpha: 1)
lbl.font = .systemFont(ofSize: 14)
lbl.textAlignment = .center
lbl.font = .systemFont(ofSize: 13, weight: .medium)
return lbl
}()
lazy var slider: UISlider = {
let s = UISlider()
s.translatesAutoresizingMaskIntoConstraints = false
s.minimumValue = 0
s.maximumValue = 127
s.transform = CGAffineTransform(rotationAngle: -.pi / 2)
return s
private lazy var modeButton: UIButton = {
let btn = UIButton(type: .system)
btn.setTitle("REL", for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 11, weight: .semibold)
btn.setTitleColor(.white, for: .normal)
btn.backgroundColor = color.withAlphaComponent(0.35)
btn.layer.cornerRadius = 4
btn.addTarget(self, action: #selector(toggleMode), for: .touchUpInside)
return btn
}()
private let valueLabel: UILabel = {
let lbl = UILabel()
lbl.textAlignment = .center
lbl.font = .monospacedSystemFont(ofSize: 13, weight: .medium)
lbl.textColor = .white
return lbl
}()
// MARK: - Init
@@ -38,51 +65,190 @@ class FaderWidget: BaseWidget {
self.color = color
super.init(uid: uid, frame: frame)
self.displayName = label
self.restingBorderColor = color.cgColor
self.layer.borderColor = color.cgColor
self.restingBorderColor = color.withAlphaComponent(0.3).cgColor
self.layer.borderColor = color.withAlphaComponent(0.3).cgColor
self.currentValue = Float(value)
self.nameLabel.text = label
self.slider.value = Float(value)
self.slider.minimumTrackTintColor = color
setupUI()
}
required init?(coder: NSCoder) { fatalError() }
// MARK: - Layout
func setupUI() {
self.addSubview(self.nameLabel)
self.nameLabel.topAnchor.constraint(equalTo: self.topAnchor, constant: 8).isActive = true
self.nameLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 4).isActive = true
self.nameLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -4).isActive = true
// MARK: - Setup
private func setupUI() {
backgroundColor = UIColor(white: 0.1, alpha: 1)
layer.cornerRadius = 6
clipsToBounds = true
self.addSubview(self.slider)
self.slider.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
self.slider.centerYAnchor.constraint(equalTo: self.centerYAnchor, constant: 10).isActive = true
let wc = self.slider.widthAnchor.constraint(equalToConstant: 216)
wc.isActive = true
self.sliderWidthConstraint = wc
topZone.backgroundColor = UIColor(white: 0.07, alpha: 1)
botZone.backgroundColor = UIColor(white: 0.07, alpha: 1)
addSubview(topZone)
addSubview(botZone)
self.slider.addTarget(self, action: #selector(self.sliderChanged), for: .valueChanged)
gradLayer.colors = [UIColor.black.withAlphaComponent(0).cgColor, color.withAlphaComponent(0.45).cgColor]
gradLayer.startPoint = CGPoint(x: 0.5, y: 1)
gradLayer.endPoint = CGPoint(x: 0.5, y: 0)
fillView.layer.addSublayer(gradLayer)
addSubview(fillView)
line.backgroundColor = color
line.layer.shadowColor = color.cgColor
line.layer.shadowRadius = 4
line.layer.shadowOpacity = 0.8
line.layer.shadowOffset = .zero
addSubview(line)
dot.backgroundColor = color
dot.layer.cornerRadius = 5
dot.layer.shadowColor = color.cgColor
dot.layer.shadowRadius = 6
dot.layer.shadowOpacity = 1
dot.layer.shadowOffset = .zero
addSubview(dot)
nameLabel.textColor = UIColor(white: 0.4, alpha: 1)
addSubview(nameLabel)
addSubview(modeButton)
addSubview(valueLabel)
arrangeCover.backgroundColor = .clear
arrangeCover.isHidden = true
addSubview(arrangeCover)
haptic.prepare()
}
// MARK: - Layout
override func layoutSubviews() {
super.layoutSubviews()
self.sliderWidthConstraint?.constant = bounds.height * 0.6
let w = bounds.width
let h = bounds.height
topZone.frame = CGRect(x: 0, y: 0, width: w, height: padHeight)
botZone.frame = CGRect(x: 0, y: h - padHeight, width: w, height: padHeight)
line.frame.size = CGSize(width: w, height: 2)
dot.frame.size = CGSize(width: 10, height: 10)
dot.layer.cornerRadius = 5
nameLabel.frame = CGRect(x: 0, y: h - padHeight, width: w, height: padHeight)
modeButton.frame = CGRect(x: w / 2 - 22, y: padHeight / 2 - 16, width: 44, height: 24)
valueLabel.frame = CGRect(x: 0, y: padHeight / 2 + 12, width: w, height: 18)
arrangeCover.frame = bounds
topZone.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }
botZone.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }
addDash(to: topZone, atBottom: true)
addDash(to: botZone, atBottom: false)
if !didInitialLayout {
didInitialLayout = true
updateDisplay()
}
}
// MARK: - Update
override func applyValue(_ value: Int) {
self.slider.value = Float(value)
private func addDash(to zone: UIView, atBottom: Bool) {
let dash = CAShapeLayer()
let y: CGFloat = atBottom ? zone.bounds.height - 1 : 0
let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: y))
path.addLine(to: CGPoint(x: zone.bounds.width, y: y))
dash.path = path.cgPath
dash.strokeColor = UIColor(white: 0.25, alpha: 1).cgColor
dash.lineWidth = 1
dash.lineDashPattern = [6, 4]
zone.layer.addSublayer(dash)
}
// MARK: - Arrange Mode
override func setArrangeMode(_ enabled: Bool) {
self.slider.isUserInteractionEnabled = (enabled == false)
arrangeMode = enabled
arrangeCover.isHidden = !enabled
}
// MARK: - Action
@objc private func sliderChanged() {
self.onSend?(self.uid, Int(self.slider.value))
// MARK: - Mode Toggle
@objc private func toggleMode() {
isRelative.toggle()
modeButton.setTitle(isRelative ? "REL" : "ABS", for: .normal)
modeButton.backgroundColor = isRelative
? color.withAlphaComponent(0.35)
: color.withAlphaComponent(0.15)
}
// MARK: - Touch
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard !arrangeMode, let pt = touches.first?.location(in: self) else { return }
touchStartY = pt.y
valueAtTouchStart = currentValue
move(to: pt)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard !arrangeMode, let pt = touches.first?.location(in: self) else { return }
move(to: pt)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {}
private func move(to pt: CGPoint) {
let activeTop = padHeight
let activeBottom = bounds.height - padHeight
let activeHeight = activeBottom - activeTop
let value: Float
if isRelative {
let deltaY = pt.y - touchStartY
let deltaPct = Float(-deltaY / activeHeight)
value = max(0, min(127, valueAtTouchStart + deltaPct * 127))
} else {
let clampedY = max(activeTop, min(activeBottom, pt.y))
value = max(0, min(127, Float((1 - (clampedY - activeTop) / activeHeight) * 127)))
}
currentValue = value
updateDisplay()
let intValue = Int(value)
if (intValue == 0 || intValue == 127) && intValue != lastHapticValue {
haptic.impactOccurred()
lastHapticValue = intValue
} else if intValue != 0 && intValue != 127 {
lastHapticValue = -1
}
if intValue != lastSentValue {
lastSentValue = intValue
onSend?(uid, intValue)
}
}
// MARK: - External value
override func applyValue(_ value: Int) {
currentValue = Float(value)
updateDisplay()
}
override func applyLabel(_ label: String) {
super.applyLabel(label)
self.nameLabel.text = label
}
// MARK: - Display
private func updateDisplay() {
let activeTop = padHeight
let activeBottom = bounds.height - padHeight
let activeHeight = activeBottom - activeTop
let w = bounds.width
let displayY = activeBottom - CGFloat(currentValue / 127) * activeHeight
let fillFrame = CGRect(x: 0, y: displayY, width: w, height: activeBottom - displayY)
CATransaction.begin()
CATransaction.setDisableActions(true)
fillView.frame = fillFrame
gradLayer.frame = fillView.bounds
CATransaction.commit()
line.frame.origin = CGPoint(x: 0, y: displayY - 1)
dot.center = CGPoint(x: w / 2, y: displayY)
valueLabel.text = "\(Int(currentValue))"
}
}
@@ -0,0 +1,308 @@
import UIKit
// Dedicated fader for the Channel Focus overlay OSC-native, so drags send a
// continuous 0.0-1.0 Double (onSendFloat) instead of the 0-127 Int the shared
// FaderWidget sends for MIDI-CC-bound preset faders. Kept as its own class,
// not a flag on FaderWidget, so the two never interfere with each other.
class FocusFaderWidget: BaseWidget {
static let size = CGSize(width: 98, height: 540)
static func defaultFrame(idx: Int) -> CGRect {
let col = idx % 4
let row = idx / 4
return CGRect(x: 16 + CGFloat(col) * 118, y: 90 + CGFloat(row) * 560, width: size.width, height: size.height)
}
// MARK: - Properties
let color: UIColor
private let padHeight: CGFloat = 90
var onSendFloat: ((String, Double) -> Void)?
private var arrangeMode = false
private var isRelative = true
private var isFine = false
private var currentValue: Float = 0
private var touchStartY: CGFloat = 0
private var valueAtTouchStart: Float = 0
private var didInitialLayout = false
private var lastHapticValue: Int = -1
private var lastSentFloatValue: Double = -1
private let haptic = UIImpactFeedbackGenerator(style: .medium)
// MARK: - Subviews
private let topZone = UIView()
private let botZone = UIView()
private let fillView = UIView()
private let gradLayer = CAGradientLayer()
private let line = UIView()
private let dot = UIView()
private let arrangeCover = UIView() // clear overlay that blocks touches in arrange mode
private let nameLabel: UILabel = {
let lbl = UILabel()
lbl.textAlignment = .center
lbl.font = .systemFont(ofSize: 13, weight: .medium)
return lbl
}()
private lazy var modeButton: UIButton = {
let btn = UIButton(type: .system)
btn.setTitle("REL", for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold)
btn.layer.cornerRadius = 5
btn.addTarget(self, action: #selector(toggleMode), for: .touchUpInside)
return btn
}()
// 10x reduced drag sensitivity for precise automation nudges overrides
// REL/ABS while engaged (fine adjustment always behaves as a relative nudge).
private lazy var fineButton: UIButton = {
let btn = UIButton(type: .system)
btn.setTitle("FINE", for: .normal)
btn.titleLabel?.font = .systemFont(ofSize: 15, weight: .bold)
btn.layer.cornerRadius = 5
btn.addTarget(self, action: #selector(toggleFine), for: .touchUpInside)
return btn
}()
// Output value readout commented out of the visible layout per request;
// still updated internally in case it's wanted back later.
private let valueLabel: UILabel = {
let lbl = UILabel()
lbl.textAlignment = .center
lbl.font = .monospacedSystemFont(ofSize: 13, weight: .medium)
lbl.textColor = .white
lbl.isHidden = true
return lbl
}()
// MARK: - Init
init(uid: String, label: String, value: Int, color: UIColor, frame: CGRect) {
self.color = color
super.init(uid: uid, frame: frame)
self.displayName = label
self.restingBorderColor = color.withAlphaComponent(0.3).cgColor
self.layer.borderColor = color.withAlphaComponent(0.3).cgColor
self.currentValue = Float(value)
self.nameLabel.text = label
setupUI()
}
required init?(coder: NSCoder) { fatalError() }
// MARK: - Setup
private func setupUI() {
backgroundColor = UIColor(white: 0.1, alpha: 1)
layer.cornerRadius = 6
clipsToBounds = true
topZone.backgroundColor = UIColor(white: 0.07, alpha: 1)
botZone.backgroundColor = UIColor(white: 0.07, alpha: 1)
addSubview(topZone)
addSubview(botZone)
gradLayer.colors = [UIColor.black.withAlphaComponent(0).cgColor, color.withAlphaComponent(0.45).cgColor]
gradLayer.startPoint = CGPoint(x: 0.5, y: 1)
gradLayer.endPoint = CGPoint(x: 0.5, y: 0)
fillView.layer.addSublayer(gradLayer)
addSubview(fillView)
line.backgroundColor = color
line.layer.shadowColor = color.cgColor
line.layer.shadowRadius = 4
line.layer.shadowOpacity = 0.8
line.layer.shadowOffset = .zero
addSubview(line)
dot.backgroundColor = color
dot.layer.cornerRadius = 5
dot.layer.shadowColor = color.cgColor
dot.layer.shadowRadius = 6
dot.layer.shadowOpacity = 1
dot.layer.shadowOffset = .zero
addSubview(dot)
nameLabel.textColor = UIColor(white: 0.4, alpha: 1)
addSubview(nameLabel)
addSubview(fineButton)
addSubview(modeButton)
addSubview(valueLabel)
arrangeCover.backgroundColor = .clear
arrangeCover.isHidden = true
addSubview(arrangeCover)
applyModeButtonStyle()
applyFineButtonStyle()
haptic.prepare()
}
// MARK: - Layout
override func layoutSubviews() {
super.layoutSubviews()
let w = bounds.width
let h = bounds.height
topZone.frame = CGRect(x: 0, y: 0, width: w, height: padHeight)
botZone.frame = CGRect(x: 0, y: h - padHeight, width: w, height: padHeight)
line.frame.size = CGSize(width: w, height: 2)
dot.frame.size = CGSize(width: 10, height: 10)
dot.layer.cornerRadius = 5
nameLabel.frame = CGRect(x: 0, y: h - padHeight, width: w, height: padHeight)
// Buttons fill the whole top dead zone now that valueLabel is hidden
// bigger targets, easier to tap.
let btnMargin: CGFloat = 6
let btnGap: CGFloat = 4
let btnWidth = w - btnMargin * 2
let btnHeight = (padHeight - btnMargin * 2 - btnGap) / 2
fineButton.frame = CGRect(x: btnMargin, y: btnMargin, width: btnWidth, height: btnHeight)
modeButton.frame = CGRect(x: btnMargin, y: fineButton.frame.maxY + btnGap, width: btnWidth, height: btnHeight)
valueLabel.frame = CGRect(x: 0, y: padHeight / 2 + 12, width: w, height: 18)
arrangeCover.frame = bounds
topZone.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }
botZone.layer.sublayers?.filter { $0 is CAShapeLayer }.forEach { $0.removeFromSuperlayer() }
addDash(to: topZone, atBottom: true)
addDash(to: botZone, atBottom: false)
if !didInitialLayout {
didInitialLayout = true
updateDisplay()
}
}
private func addDash(to zone: UIView, atBottom: Bool) {
let dash = CAShapeLayer()
let y: CGFloat = atBottom ? zone.bounds.height - 1 : 0
let path = UIBezierPath()
path.move(to: CGPoint(x: 0, y: y))
path.addLine(to: CGPoint(x: zone.bounds.width, y: y))
dash.path = path.cgPath
dash.strokeColor = UIColor(white: 0.25, alpha: 1).cgColor
dash.lineWidth = 1
dash.lineDashPattern = [6, 4]
zone.layer.addSublayer(dash)
}
// MARK: - Arrange Mode
override func setArrangeMode(_ enabled: Bool) {
arrangeMode = enabled
arrangeCover.isHidden = !enabled
}
// MARK: - Mode Toggle
@objc private func toggleMode() {
isRelative.toggle()
modeButton.setTitle(isRelative ? "REL" : "ABS", for: .normal)
applyModeButtonStyle()
}
private func applyModeButtonStyle() {
// Unmistakable on/off: solid color fill vs. dim gray, same convention
// as the rest of the app's toggle buttons not just an alpha shift.
modeButton.backgroundColor = isRelative ? color : UIColor(white: 0.15, alpha: 1)
modeButton.setTitleColor(isRelative ? .black : .lightGray, for: .normal)
}
@objc private func toggleFine() {
isFine.toggle()
applyFineButtonStyle()
}
private func applyFineButtonStyle() {
fineButton.backgroundColor = isFine ? color : UIColor(white: 0.15, alpha: 1)
fineButton.setTitleColor(isFine ? .black : .lightGray, for: .normal)
}
// MARK: - Touch
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
guard !arrangeMode, let pt = touches.first?.location(in: self) else { return }
touchStartY = pt.y
valueAtTouchStart = currentValue
move(to: pt)
}
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
guard !arrangeMode, let pt = touches.first?.location(in: self) else { return }
move(to: pt)
}
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {}
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {}
private func move(to pt: CGPoint) {
let activeTop = padHeight
let activeBottom = bounds.height - padHeight
let activeHeight = activeBottom - activeTop
let value: Float
if isFine {
// Fine always behaves as a relative nudge, at 1/10th sensitivity,
// regardless of the REL/ABS setting for precise automation moves.
let deltaY = pt.y - touchStartY
let deltaPct = Float(-deltaY / activeHeight)
value = max(0, min(127, valueAtTouchStart + deltaPct * 127 * 0.1))
} else if isRelative {
let deltaY = pt.y - touchStartY
let deltaPct = Float(-deltaY / activeHeight)
value = max(0, min(127, valueAtTouchStart + deltaPct * 127))
} else {
let clampedY = max(activeTop, min(activeBottom, pt.y))
value = max(0, min(127, Float((1 - (clampedY - activeTop) / activeHeight) * 127)))
}
currentValue = value
updateDisplay()
let intValue = Int(value)
if (intValue == 0 || intValue == 127) && intValue != lastHapticValue {
haptic.impactOccurred()
lastHapticValue = intValue
} else if intValue != 0 && intValue != 127 {
lastHapticValue = -1
}
let normalized = Double(value) / 127.0
if lastSentFloatValue < 0 || abs(normalized - lastSentFloatValue) > 0.0005 {
lastSentFloatValue = normalized
onSendFloat?(uid, normalized)
}
}
// MARK: - External value
override func applyFloatValue(_ value: Double) {
currentValue = Float(value * 127)
updateDisplay()
}
override func applyLabel(_ label: String) {
super.applyLabel(label)
self.nameLabel.text = label
}
// MARK: - Display
private func updateDisplay() {
let activeTop = padHeight
let activeBottom = bounds.height - padHeight
let activeHeight = activeBottom - activeTop
let w = bounds.width
let displayY = activeBottom - CGFloat(currentValue / 127) * activeHeight
let fillFrame = CGRect(x: 0, y: displayY, width: w, height: activeBottom - displayY)
CATransaction.begin()
CATransaction.setDisableActions(true)
fillView.frame = fillFrame
gradLayer.frame = fillView.bounds
CATransaction.commit()
line.frame.origin = CGPoint(x: 0, y: displayY - 1)
dot.center = CGPoint(x: w / 2, y: displayY)
valueLabel.text = String(format: "%.3f", currentValue / 127)
}
}
@@ -0,0 +1,130 @@
import UIKit
// Read-only OSC feedback display for Channel Focus shows whatever text the
// desktop last learned/received on this widget's bound address (e.g. track
// name, bar, time). A plain large label, same spirit as TitleWidget: no
// background, no border, just text and always sized to exactly fit that
// text, no baked-in minimum box. Pinch adjusts font size directly (not an
// independent bounds size), so the frame is always a tight fit and aspect
// ratio can't drift out of sync with the text.
class FocusFeedbackWidget: BaseWidget, TextAlignableWidget, TextColorableWidget {
private static let padding: CGFloat = 8
static let defaultFontSize: CGFloat = 32
static let minFontSize: CGFloat = 12
static func defaultFrame(idx: Int, text: String = "") -> CGRect {
let size = fittedSize(for: text, fontSize: defaultFontSize)
let col = idx % 3
let row = idx / 3
return CGRect(x: 16 + CGFloat(col) * (size.width + 20), y: 640 + CGFloat(row) * (size.height + 12),
width: size.width, height: size.height)
}
private static func fittedSize(for text: String, fontSize: CGFloat) -> CGSize {
let font = UIFont.monospacedDigitSystemFont(ofSize: fontSize, weight: .semibold)
let textSize = (text.isEmpty ? "" : text as NSString).size(withAttributes: [.font: font])
return CGSize(width: ceil(textSize.width) + padding * 2, height: ceil(textSize.height) + padding * 2)
}
// MARK: - Properties
private(set) var fontSize: CGFloat = defaultFontSize
// MARK: - Subviews
private let textLabel: UILabel = {
let lbl = UILabel()
lbl.translatesAutoresizingMaskIntoConstraints = false
lbl.textColor = UIColor(white: 0.92, alpha: 1)
lbl.textAlignment = .center
lbl.numberOfLines = 1
return lbl
}()
// MARK: - Init
init(uid: String, label: String, text: String, frame: CGRect) {
super.init(uid: uid, frame: frame)
self.displayName = label
self.backgroundColor = .clear
self.restingBorderColor = UIColor.clear.cgColor
self.layer.borderColor = UIColor.clear.cgColor
self.textLabel.text = text
// frame may be a fresh default (already sized for defaultFontSize) or
// a restored, possibly pinch-resized, saved layout frame derive the
// font size that actually matches whichever one we were given, so
// resizeToFitText() converges instead of snapping back to default.
let defaultHeight = Self.fittedSize(for: text, fontSize: Self.defaultFontSize).height
if defaultHeight > 0 {
let ratio = frame.height / defaultHeight
self.fontSize = max(Self.minFontSize, Self.defaultFontSize * ratio)
}
self.textLabel.font = .monospacedDigitSystemFont(ofSize: fontSize, weight: .semibold)
setupUI()
}
required init?(coder: NSCoder) { fatalError() }
// MARK: - Layout
private func setupUI() {
addSubview(textLabel)
textLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.padding).isActive = true
textLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.padding).isActive = true
textLabel.topAnchor.constraint(equalTo: topAnchor, constant: Self.padding).isActive = true
textLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -Self.padding).isActive = true
}
// MARK: - Arrange Mode
override func setArrangeMode(_ enabled: Bool) {}
// MARK: - Sizing
/// Sets the font size and re-fits the frame around the current text,
/// keeping the widget's center fixed.
func setFontSize(_ size: CGFloat) {
fontSize = max(Self.minFontSize, size)
textLabel.font = .monospacedDigitSystemFont(ofSize: fontSize, weight: .semibold)
resizeToFitText()
}
private func resizeToFitText() {
let text = textLabel.text ?? ""
let newSize = Self.fittedSize(for: text, fontSize: fontSize)
let center = self.center
self.bounds.size = newSize
self.center = center
}
// MARK: - External updates
override func applyFeedbackText(_ text: String) {
textLabel.text = text
resizeToFitText()
}
// MARK: - Alignment
var textAlignmentName: String {
get {
switch textLabel.textAlignment {
case .left: return "left"
case .right: return "right"
default: return "center"
}
}
set {
switch newValue {
case "left": textLabel.textAlignment = .left
case "right": textLabel.textAlignment = .right
default: textLabel.textAlignment = .center
}
}
}
// MARK: - Text Color
private var _textColorName: String = "White"
var textColorName: String {
get { _textColorName }
set {
_textColorName = newValue
textLabel.textColor = UIColor.fromHex(NamedColorPalette.hex(for: newValue))
}
}
}
@@ -0,0 +1,127 @@
import UIKit
// Dedicated toggle for the Channel Focus overlay kept as its own class,
// not shared with the regular ToggleWidget, so Channel-Focus-only behavior
// (momentary triggers, color/darken/ring tuning) never affects the plain
// preset toggles that ToggleWidget still serves.
class FocusToggleWidget: BaseWidget {
static let size = CGSize(width: 75, height: 75)
static func defaultFrame(idx: Int) -> CGRect {
let gap: CGFloat = 12
let col = idx % 6
let row = idx / 6
return CGRect(x: 16 + CGFloat(col) * (size.width + gap),
y: 420 + CGFloat(row) * (size.height + gap),
width: size.width, height: size.height)
}
// MARK: - Properties
let color: UIColor
// The desktop's actual palette name for `color` (e.g. "Gray") tells us
// definitively when the heavy off-state darken/ring treatment should be
// skipped, rather than guessing from the resolved color's saturation
// (which can't tell a true neutral gray apart from white; both read as
// ~0 saturation).
let colorName: String
var isOn: Bool
// When true, the button's own UI feedback on tap shouldn't persist
// there's no real "on" state for a momentary action to reflect (e.g. Next
// Channel), so only actual feedback from the desktop (applyValue, via a
// widget_update broadcast) should ever light the background.
let isMomentary: Bool
// MARK: - Subviews
lazy var button: UIButton = {
let btn = UIButton(type: .system)
btn.translatesAutoresizingMaskIntoConstraints = false
btn.layer.cornerRadius = 4
btn.titleLabel?.font = .systemFont(ofSize: 12, weight: .semibold)
return btn
}()
// MARK: - Init
init(uid: String, label: String, isOn: Bool, color: UIColor, colorName: String, isMomentary: Bool = false, frame: CGRect) {
self.color = color
self.colorName = colorName
self.isOn = isOn
self.isMomentary = isMomentary
super.init(uid: uid, frame: frame)
self.displayName = label
// Blends the margin between the button and the border into the
// app's black background, instead of BaseWidget's shared gray default.
self.backgroundColor = .black
self.button.setTitle(label, for: .normal)
setupUI()
applyState()
}
required init?(coder: NSCoder) { fatalError() }
// MARK: - Layout
func setupUI() {
self.addSubview(self.button)
self.button.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true
self.button.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
self.button.widthAnchor.constraint(equalToConstant: 63).isActive = true
self.button.heightAnchor.constraint(equalToConstant: 63).isActive = true
self.button.addTarget(self, action: #selector(self.buttonTappedToggleButton), for: .touchUpInside)
}
// MARK: - State
func applyState() {
if self.colorName == "Gray" {
// Gray borrows ToggleWidget's plain scheme exactly: flat off-fill,
// and a border that stays the same static neutral gray regardless
// of on/off Gray has no real "color" of its own to dim or ring.
self.button.backgroundColor = self.isOn ? self.color : UIColor(white: 0.15, alpha: 1)
self.restingBorderColor = UIColor(white: 0.27, alpha: 1).cgColor
} else if self.colorName == "White" {
// White has no hue either, so the 0.8 darken used for saturated
// colors crushes it down to the same dark gray as everything
// else. Keep its off-state noticeably brighter so it still
// reads as "dim white" instead of "generic dark gray."
self.button.backgroundColor = self.isOn ? self.color : UIColor(white: 0.45, alpha: 1)
self.restingBorderColor = UIColor(white: 0.45, alpha: 1).cgColor
} else {
self.button.backgroundColor = self.isOn ? self.color : self.color.darkened(by: 0.8)
let offBorderColor = self.color.withAlphaComponent(0.675)
self.restingBorderColor = self.isOn ? self.color.cgColor : offBorderColor.cgColor
}
self.button.setTitleColor(self.isOn ? .black : .lightGray, for: .normal)
self.layer.borderColor = self.restingBorderColor
}
override func applyValue(_ value: Int) {
self.isOn = value > 63
applyState()
}
override func applyLabel(_ label: String) {
super.applyLabel(label)
self.button.setTitle(label, for: .normal)
}
// MARK: - Arrange Mode
override func setArrangeMode(_ enabled: Bool) {
self.button.isUserInteractionEnabled = (enabled == false)
}
// MARK: - Action
@objc private func buttonTappedToggleButton() {
if isMomentary {
// Just send the trigger don't touch isOn/applyState locally.
// UIButton's own built-in touch-down highlight is the only visual
// feedback a momentary tap gets; the persistent background is
// reserved for real feedback-confirmed state.
self.onSend?(self.uid, 127)
return
}
self.isOn = !self.isOn
applyState()
self.onSend?(self.uid, self.isOn ? 127 : 0)
}
}
@@ -0,0 +1,35 @@
import UIKit
class GridView: UIView {
var gridSize: CGFloat = 25 {
didSet { setNeedsDisplay() }
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isUserInteractionEnabled = false
}
required init?(coder: NSCoder) { fatalError() }
override func draw(_ rect: CGRect) {
guard let ctx = UIGraphicsGetCurrentContext() else { return }
ctx.setStrokeColor(UIColor(white: 1, alpha: 0.315).cgColor)
ctx.setLineWidth(0.5)
var x: CGFloat = 0
while x <= rect.width {
ctx.move(to: CGPoint(x: x, y: 0))
ctx.addLine(to: CGPoint(x: x, y: rect.height))
x += gridSize
}
var y: CGFloat = 0
while y <= rect.height {
ctx.move(to: CGPoint(x: 0, y: y))
ctx.addLine(to: CGPoint(x: rect.width, y: y))
y += gridSize
}
ctx.strokePath()
}
}
@@ -1,6 +1,6 @@
import UIKit
class TitleWidget: BaseWidget {
class TitleWidget: BaseWidget, TextAlignableWidget {
static func defaultFrame(idx: Int) -> CGRect {
let col = idx % 3
@@ -20,8 +20,14 @@ class TitleWidget: BaseWidget {
// MARK: - Init
init(uid: String, text: String, frame: CGRect) {
super.init(uid: uid, frame: frame)
let textWidth = ceil((text as NSString).size(withAttributes: [
.font: UIFont.systemFont(ofSize: 22, weight: .semibold)
]).width) + 8
var tightFrame = frame
tightFrame.size.width = textWidth
super.init(uid: uid, frame: tightFrame)
self.backgroundColor = .clear
self.restingBorderColor = UIColor.clear.cgColor
self.layer.borderColor = UIColor.clear.cgColor
self.titleLabel.text = text
setupUI()
@@ -37,4 +43,22 @@ class TitleWidget: BaseWidget {
self.titleLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true
}
// MARK: - Alignment
var textAlignmentName: String {
get {
switch titleLabel.textAlignment {
case .center: return "center"
case .right: return "right"
default: return "left"
}
}
set {
switch newValue {
case "center": titleLabel.textAlignment = .center
case "right": titleLabel.textAlignment = .right
default: titleLabel.textAlignment = .left
}
}
}
}
@@ -62,6 +62,11 @@ class ToggleWidget: BaseWidget {
applyState()
}
override func applyLabel(_ label: String) {
super.applyLabel(label)
self.button.setTitle(label, for: .normal)
}
// MARK: - Arrange Mode
override func setArrangeMode(_ enabled: Bool) {
self.button.isUserInteractionEnabled = (enabled == false)
@@ -13,6 +13,9 @@ class TransportWidget: BaseWidget {
width: size.width, height: size.height)
}
// MARK: - Properties
let color: UIColor
// MARK: - Subviews
lazy var button: UIButton = {
let btn = UIButton(type: .system)
@@ -25,7 +28,8 @@ class TransportWidget: BaseWidget {
}()
// MARK: - Init
init(uid: String, label: String, frame: CGRect) {
init(uid: String, label: String, color: UIColor, frame: CGRect) {
self.color = color
super.init(uid: uid, frame: frame)
self.displayName = label
self.layer.borderColor = UIColor(white: 0.27, alpha: 1).cgColor
@@ -54,6 +58,18 @@ class TransportWidget: BaseWidget {
// MARK: - Action
@objc private func buttonTappedTransportButton() {
self.onSend?(self.uid, 127)
flashBackground()
}
private func flashBackground() {
let restColor = UIColor(white: 0.15, alpha: 1)
UIView.animate(withDuration: 0.015, animations: {
self.button.backgroundColor = self.color
}, completion: { _ in
UIView.animate(withDuration: 0.015) {
self.button.backgroundColor = restColor
}
})
}
}
@@ -2,6 +2,12 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Used to discover the Virtual Controller desktop app on your local network.</string>
<key>NSBonjourServices</key>
<array>
<string>_vcremote._tcp.</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
+1
View File
@@ -4,3 +4,4 @@ PyQt6_sip==13.11.1
python-osc==1.10.2
python-rtmidi==1.5.8
uv==0.11.21
zeroconf==0.150.0
+15
View File
@@ -6,6 +6,7 @@ from PyQt6.QtNetwork import QHostAddress
class WSServer(QObject):
control_received = pyqtSignal(str, int)
control_f_received = pyqtSignal(str, float)
layout_saved = pyqtSignal(str, object)
widget_visibility_received = pyqtSignal(str, int)
client_connected = pyqtSignal()
@@ -48,6 +49,11 @@ class WSServer(QObject):
val = int(data["value"])
self.log_signal.emit(f"control uid={uid[:8]}… val={val}")
self.control_received.emit(uid, val)
elif ev == "control_f":
uid = data["uid"]
val = float(data["value"])
self.log_signal.emit(f"control_f uid={uid[:8]}… val={val:.4f}")
self.control_f_received.emit(uid, val)
elif ev == "save_layout":
n = len(data.get("layout", {}))
self.log_signal.emit(f"layout saved preset={data['preset_uuid'][:8]}{n} widgets")
@@ -82,6 +88,15 @@ class WSServer(QObject):
def broadcast_widget_update(self, uid: str, value: int):
self.broadcast({"event": "widget_update", "uid": uid, "value": value})
def broadcast_widget_update_f(self, uid: str, value: float):
self.broadcast({"event": "widget_update_f", "uid": uid, "value": value})
def broadcast_widget_label(self, uid: str, label: str):
self.broadcast({"event": "widget_label", "uid": uid, "label": label})
def broadcast_widget_feedback(self, uid: str, text: str):
self.broadcast({"event": "widget_feedback", "uid": uid, "text": text})
def broadcast_daw_state(self, **kwargs):
self.broadcast({"event": "daw_state", **kwargs})