Extend REAPER extension to read/write automation items, wire desktop app to it over UDP

extension-reaper-macos: split main.cpp into tracking.cpp (polls selected
envelope/automation items, reads/writes D_BASELINE) and socket.cpp (basic
UDP listener, non-blocking, drains all pending packets per tick). Nudging
now applies to every selected automation item across every track in the
project, not just the first one found.

app-desktop-macos: new "Extension" column (mirrors the OSC/Remote columns)
to enable/disable the UDP connection and point it at a host/port, plus a
log window. FocusFaderWidget gets an OSC/Ext output-mode toggle (mirrors
TransportWidget's MIDI/OSC toggle) so a fader can drive the extension
directly instead of/alongside OSC. Also renamed the Remote/Tablet buttons
for clarity and split their status indicator into its own column.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Paul Lipscomb
2026-07-16 19:13:34 -04:00
parent cd5ebd84d6
commit 7d4f8a4cd2
11 changed files with 786 additions and 57 deletions
+69 -7
View File
@@ -19,6 +19,7 @@ from zonebutton import ZoneButton
from colorswatchpopup import ColorSwatchPopup
from osc_sender import send_osc_message
from midi_sender import send_cc
from extension_sender import send_to_extension
class FocusFaderWidget(QWidget):
@@ -96,6 +97,29 @@ class FocusFaderWidget(QWidget):
_grp2_layout.setContentsMargins(4, 4, 4, 4)
_grp2_layout.setSpacing(3)
# OSC / Ext mode selector
mode_row = QHBoxLayout()
mode_row.setContentsMargins(0, 0, 0, 0)
mode_row.setSpacing(2)
self.osc_mode_btn = QPushButton("OSC")
self.osc_mode_btn.setFixedHeight(18)
self.osc_mode_btn.setCheckable(True)
self.osc_mode_btn.setChecked(True)
self.osc_mode_btn.setStyleSheet("border: none;")
self.ext_mode_btn = QPushButton("Ext")
self.ext_mode_btn.setFixedHeight(18)
self.ext_mode_btn.setCheckable(True)
self.ext_mode_btn.setChecked(False)
self.ext_mode_btn.setStyleSheet("border: none;")
self.osc_mode_btn.clicked.connect(lambda: self.set_output_mode("osc"))
self.ext_mode_btn.clicked.connect(lambda: self.set_output_mode("ext"))
mode_row.addWidget(self.osc_mode_btn)
mode_row.addWidget(self.ext_mode_btn)
_grp2_layout.addLayout(mode_row)
self.output_mode = "osc"
self._last_ext_value = None # tracks previous fader value, for delta-on-move in Ext mode
# OSC address field
self.osc_addr_out_edit = QLineEdit("/track/volume")
self.osc_addr_out_edit.setAlignment(Qt.AlignmentFlag.AlignHCenter)
@@ -376,6 +400,24 @@ class FocusFaderWidget(QWidget):
self.touch_sense_mode = "jl_cooper" if index == 1 else "off"
self.is_touching = False
def set_output_mode(self, mode):
self.output_mode = mode
if mode == "osc":
self.osc_mode_btn.setChecked(True)
self.ext_mode_btn.setChecked(False)
self.osc_mode_btn.setStyleSheet("font-weight: bold; color: #00e676;")
self.ext_mode_btn.setStyleSheet("")
self.osc_addr_out_edit.setVisible(True)
self.cc_output_lbl.setText(self._osc_output_label(self.fader.value()))
else:
self.ext_mode_btn.setChecked(True)
self.osc_mode_btn.setChecked(False)
self.ext_mode_btn.setStyleSheet("font-weight: bold; color: #00e676;")
self.osc_mode_btn.setStyleSheet("")
self.osc_addr_out_edit.setVisible(False)
self.cc_output_lbl.setText("Ext [--]")
self._last_ext_value = None # reset delta tracking on mode switch
def receive_osc_value(self, value):
"""Update the fader to reflect feedback from the DAW, without echoing it back out."""
if self.is_touching:
@@ -449,8 +491,11 @@ class FocusFaderWidget(QWidget):
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.output_mode == "ext":
self.cc_output_lbl.setText(f"Ext [{value}]")
else:
self.cc_output_lbl.setText(self._osc_output_label(value))
if self.pickup_mode and self.hunting:
crossed = (
(self.prev_position < self.last_sent <= value) or
@@ -462,17 +507,32 @@ class FocusFaderWidget(QWidget):
self._stop_blink()
self._set_readout_style("orange")
self.last_sent = value
send_osc_message(addr, value / 127.0)
self._send_hw_feedback(value)
self._emit_value(value)
else:
self.readout.setText(f"{addr}{value}")
self.readout.setText(f"[{value}]")
return
else:
self.last_sent = value
self.prev_position = value
self._emit_value(value)
self.readout.setText(f"[{value}]")
def _emit_value(self, value):
"""Sends the current fader value out via whichever protocol is
selected. Ext mode sends a delta (change since last move), not the
absolute position — the extension's UDP protocol only understands
deltas added to the current baseline, not absolute targets."""
if self.output_mode == "ext":
if self._last_ext_value is None:
self._last_ext_value = value
delta = (value - self._last_ext_value) / 127.0
self._last_ext_value = value
if delta != 0:
send_to_extension(delta)
else:
addr = self.osc_addr_out_edit.text().strip() or "/track/volume"
send_osc_message(addr, value / 127.0)
self._send_hw_feedback(value)
self.readout.setText(f"{addr}{value}")
self._send_hw_feedback(value)
def _send_hw_feedback(self, value):
"""Sends a translated MIDI CC out (e.g. so a motorized fader's position
@@ -558,6 +618,7 @@ class FocusFaderWidget(QWidget):
"hw_out_cc": self.hw_cc_spin.value(),
"hw_out_ch": self.hw_ch_spin.value(),
"touch_sense_mode": self.touch_sense_mode,
"output_mode": self.output_mode,
}
def set_state(self, state):
@@ -599,3 +660,4 @@ class FocusFaderWidget(QWidget):
self.touch_sense_combo.blockSignals(False)
self.is_touching = False
self._update_hw_visibility()
self.set_output_mode(state.get("output_mode", "osc"))