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>
This commit is contained in:
Paul Lipscomb
2026-07-06 22:17:37 -04:00
parent d6201c59b4
commit 0c7b6b4442
34 changed files with 4815 additions and 208 deletions
+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())