diff --git a/.claude/settings.json b/.claude/settings.json
index 424bcf7..0cbda98 100644
--- a/.claude/settings.json
+++ b/.claude/settings.json
@@ -35,7 +35,13 @@
"Bash(nm -gU reaper_extension-reaper-macos.dylib)",
"Bash(ls \"$HOME/Library/Application Support/REAPER/UserPlugins\" | grep -i reaper_ | head -5)",
"Bash(ls -la \"$HOME/Library/Application Support/REAPER/UserPlugins\")",
- "Bash(cp reaper_extension-reaper-macos.dylib '/Users/p4piwabl0/Library/Application Support/REAPER/UserPlugins/')"
+ "Bash(cp reaper_extension-reaper-macos.dylib '/Users/p4piwabl0/Library/Application Support/REAPER/UserPlugins/')",
+ "Bash(tar --exclude='*.dylib' -czf ~/Desktop/extension-reaper-macos-skeleton-backup-2026-07-15.tar.gz extension-reaper-macos)",
+ "Read(//Users/p4piwabl0/Desktop/**)",
+ "Bash(awk '/int REAPERAPI_LoadAPI/,0' /Users/p4piwabl0/Desktop/projects/virtual-controller-clean/extension-reaper-macos/vendor/reaper-sdk/sdk/reaper_plugin_functions.h)",
+ "Bash(grep -n \"{NULL, NULL}\")",
+ "Bash(ls -la \"/Applications/REAPER.app/Contents/MacOS/\" 2>&1 | head -5 *)",
+ "Read(//Applications/REAPER.app/Contents/MacOS/**)"
]
}
}
diff --git a/app-desktop-macos/extension_sender.py b/app-desktop-macos/extension_sender.py
new file mode 100644
index 0000000..46c6fbc
--- /dev/null
+++ b/app-desktop-macos/extension_sender.py
@@ -0,0 +1,47 @@
+import socket
+
+_ext_socket = None
+_ext_ip = "127.0.0.1"
+_ext_port = 9124
+_ext_enabled = False
+
+
+def get_extension_socket():
+ global _ext_socket
+
+ if _ext_socket is None:
+ _ext_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+
+ return _ext_socket
+
+
+def set_extension_target(ip=None, port=None):
+ global _ext_socket, _ext_ip, _ext_port
+
+ if ip is not None:
+ _ext_ip = ip
+
+ if port is not None:
+ _ext_port = port
+
+ _ext_socket = None
+
+
+def set_extension_enabled(enabled):
+ global _ext_enabled
+ _ext_enabled = enabled
+
+
+def is_extension_enabled():
+ return _ext_enabled
+
+
+def send_to_extension(value):
+ """Sends a plain numeric delta to extension-reaper-macos over UDP.
+ No-op (kill switch) unless set_extension_enabled(True) has been called."""
+ if not _ext_enabled:
+ return
+ try:
+ get_extension_socket().sendto(str(value).encode(), (_ext_ip, _ext_port))
+ except Exception as e:
+ print(f"[Extension send] Error: {e}")
diff --git a/app-desktop-macos/focusfaderwidget.py b/app-desktop-macos/focusfaderwidget.py
index bc0dcc8..1dbef9e 100644
--- a/app-desktop-macos/focusfaderwidget.py
+++ b/app-desktop-macos/focusfaderwidget.py
@@ -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"))
diff --git a/app-desktop-macos/main.py b/app-desktop-macos/main.py
index 92e5a6b..3e8d152 100644
--- a/app-desktop-macos/main.py
+++ b/app-desktop-macos/main.py
@@ -75,6 +75,13 @@ from osc_sender import (
set_osc_target,
)
+from extension_sender import (
+ send_to_extension,
+ set_extension_target,
+ set_extension_enabled,
+ is_extension_enabled,
+)
+
from ws_server import WSServer
def set_window(w):
@@ -355,6 +362,67 @@ picker_transport_out.setFixedWidth(200)
vstack_osc = QVBoxLayout()
vstack_osc.setSpacing(2)
+# Layout: vstack_remote — Remote's own self-contained column (indicator,
+# address, buttons), placed beside vstack_osc rather than mixed into it, so
+# the Remote status rows align above the Remote buttons, not OSC's.
+vstack_remote = QVBoxLayout()
+vstack_remote.setSpacing(2)
+
+# Layout: hstack_remote_buttons
+hstack_remote_buttons = QHBoxLayout()
+
+# Layout: hstack_osc_and_remote — holds OSC, Remote, and Extension columns
+# side by side (name kept from when it only held two, to avoid churn)
+hstack_osc_and_remote = QHBoxLayout()
+hstack_osc_and_remote.setSpacing(16)
+
+# Layout: vstack_extension — Extension's own column, mirrors vstack_osc's
+# row structure exactly (activity, address, port, buttons — no stretch
+# needed since it has the same number of rows as OSC's column).
+vstack_extension = QVBoxLayout()
+vstack_extension.setSpacing(2)
+
+# Layout: hstack_extension_activity_and_label
+hstack_extension_activity_and_label = QHBoxLayout()
+
+# Layout: hstack_extension_ip
+hstack_extension_ip = QHBoxLayout()
+
+# Layout: hstack_extension_port
+hstack_extension_port = QHBoxLayout()
+
+# Layout: hstack_extension_buttons
+hstack_extension_buttons = QHBoxLayout()
+
+# Widget: activity_indicator_extension
+activity_indicator_extension = QLabel("●")
+activity_indicator_extension.setStyleSheet("color: #444; font-size: 14px; background-color: rgba(255, 0, 0, 0.15); border: 1px solid red; padding: 2px;")
+
+# Widget: label_connection_extension
+label_connection_extension = QLabel("Not Connected")
+label_connection_extension.setStyleSheet("color: #888; background-color: rgba(0, 0, 255, 0.15); border: 1px solid blue; padding: 2px;")
+
+# Widget: textbox_extension_ip
+textbox_extension_ip = QLineEdit("127.0.0.1")
+textbox_extension_ip.setFixedWidth(110)
+textbox_extension_ip.setPlaceholderText("127.0.0.1")
+
+# Widget: spinner_extension_port
+spinner_extension_port = QSpinBox()
+spinner_extension_port.setMinimum(1024)
+spinner_extension_port.setMaximum(65535)
+spinner_extension_port.setValue(9124)
+spinner_extension_port.setFixedWidth(65)
+
+# Widget: button_extension_toggle
+button_extension_toggle = QPushButton("Connect Extension")
+button_extension_toggle.setFixedWidth(130)
+
+# Widget: button_extension_log
+button_extension_log = QPushButton("Log")
+button_extension_log.setFixedWidth(65)
+button_extension_log.setCheckable(True)
+
# Layout: hstack_osc_activity_and_label
hstack_osc_activity_and_label = QHBoxLayout()
@@ -399,26 +467,40 @@ button_osc_toggle = QPushButton("Start OSC")
button_osc_toggle.setFixedWidth(90)
# Widget: button_tablet_server
-button_tablet_server = QPushButton("Start Tablet")
-button_tablet_server.setFixedWidth(90)
+button_tablet_server = QPushButton("Enable Remote")
+button_tablet_server.setFixedWidth(110)
# Widget: button_tablet_log
-button_tablet_log = QPushButton("Tablet")
-button_tablet_log.setFixedWidth(65)
+button_tablet_log = QPushButton("Remote Log")
+button_tablet_log.setFixedWidth(85)
button_tablet_log.setCheckable(True)
# Widget: button_ws_log
-button_ws_log = QPushButton("WS")
-button_ws_log.setFixedWidth(50)
+button_ws_log = QPushButton("WS Log")
+button_ws_log.setFixedWidth(65)
button_ws_log.setCheckable(True)
# Widget: label_tablet_status
label_tablet_status = QLabel("")
label_tablet_status.setStyleSheet("color: #8e24aa; font-size: 11px; font-family: monospace;")
+# Layout: hstack_remote_activity_and_label
+hstack_remote_activity_and_label = QHBoxLayout()
+
+# Layout: hstack_remote_ip
+hstack_remote_ip = QHBoxLayout()
+
+# Widget: activity_indicator_remote
+activity_indicator_remote = QLabel("●")
+activity_indicator_remote.setStyleSheet("color: #444; font-size: 14px; background-color: rgba(255, 0, 0, 0.15); border: 1px solid red; padding: 2px;")
+
+# Widget: label_connection_remote
+label_connection_remote = QLabel("Not Listening")
+label_connection_remote.setStyleSheet("color: #888; background-color: rgba(0, 0, 255, 0.15); border: 1px solid blue; padding: 2px;")
+
# Widget: button_osc_messages_log
-button_osc_messages_log = QPushButton("Messages")
-button_osc_messages_log.setFixedWidth(80)
+button_osc_messages_log = QPushButton("OSC/MIDI Log")
+button_osc_messages_log.setFixedWidth(100)
button_osc_messages_log.setCheckable(True)
# Widget: button_layout_panel
@@ -722,6 +804,34 @@ textedit_tablet_log.setStyleSheet("""
}
""")
+# Container: container_extension_log
+container_extension_log = QWidget(None, Qt.WindowType.Window)
+container_extension_log.setWindowTitle("Extension")
+container_extension_log.setStyleSheet("background-color: #1a1a1a;")
+container_extension_log.setFixedWidth(360)
+
+vstack_extension_log = QVBoxLayout(container_extension_log)
+vstack_extension_log.setContentsMargins(6, 6, 6, 6)
+vstack_extension_log.setSpacing(4)
+
+hstack_extension_log_header = QHBoxLayout()
+label_extension_log_title = QLabel("Extension")
+label_extension_log_title.setStyleSheet("color: #00bcd4; font-weight: bold; font-size: 11px;")
+button_extension_log_clear = QPushButton("Clear")
+button_extension_log_clear.setFixedSize(45, 18)
+
+textedit_extension_log = QTextEdit()
+textedit_extension_log.setReadOnly(True)
+textedit_extension_log.setStyleSheet("""
+ QTextEdit {
+ background-color: #111111;
+ color: #00bcd4;
+ font-family: monospace;
+ font-size: 10px;
+ border: none;
+ }
+""")
+
# MARK: LAYOUT PANEL
container_layout_panel = QWidget(None, Qt.WindowType.Window)
container_layout_panel.setWindowTitle("Layout")
@@ -1263,24 +1373,73 @@ class MainWindow(QMainWindow):
hstack_osc_ports.addWidget(spinner_osc_send_port)
hstack_osc_ports.addStretch()
- # build hstack for control buttons
+ # build hstack for control buttons (OSC's own only — Remote's live
+ # in hstack_remote_buttons instead, in their own column)
hstack_osc_buttons.addWidget(button_osc_toggle)
hstack_osc_buttons.addWidget(button_osc_messages_log)
hstack_osc_buttons.addWidget(button_layout_panel)
hstack_osc_buttons.addWidget(button_focus_panel)
- hstack_osc_buttons.addSpacing(16)
- hstack_osc_buttons.addWidget(button_tablet_server)
- hstack_osc_buttons.addWidget(button_tablet_log)
- hstack_osc_buttons.addWidget(button_ws_log)
- hstack_osc_buttons.addWidget(label_tablet_status)
hstack_osc_buttons.addStretch()
- # add all rows to vstack
+ # build hstack with activity indicator and connection label (Remote)
+ hstack_remote_activity_and_label.addWidget(activity_indicator_remote)
+ hstack_remote_activity_and_label.addWidget(label_connection_remote)
+ hstack_remote_activity_and_label.addStretch()
+
+ # build hstack for the remote's ws:// address
+ hstack_remote_ip.addWidget(label_tablet_status)
+ hstack_remote_ip.addStretch()
+
+ # build hstack for the remote's own buttons
+ hstack_remote_buttons.addWidget(button_tablet_server)
+ hstack_remote_buttons.addWidget(button_tablet_log)
+ hstack_remote_buttons.addWidget(button_ws_log)
+ hstack_remote_buttons.addStretch()
+
+ # add OSC's rows to its own vstack
vstack_osc.addLayout(hstack_osc_activity_and_label) # activity indicator and connection label
vstack_osc.addLayout(hstack_osc_ip) # DAW IP input
vstack_osc.addLayout(hstack_osc_ports) # listen and send ports
vstack_osc.addLayout(hstack_osc_buttons) # control buttons
+ # add Remote's rows to its own vstack, so its indicator/address sit
+ # directly above its own buttons, not OSC's
+ vstack_remote.addLayout(hstack_remote_activity_and_label) # remote activity indicator and connection label
+ vstack_remote.addLayout(hstack_remote_ip) # remote ws:// address
+ vstack_remote.addStretch()
+ vstack_remote.addLayout(hstack_remote_buttons) # remote control buttons
+
+ # build hstack with activity indicator and connection label (Extension)
+ hstack_extension_activity_and_label.addWidget(activity_indicator_extension)
+ hstack_extension_activity_and_label.addWidget(label_connection_extension)
+ hstack_extension_activity_and_label.addStretch()
+
+ # build hstack for the extension's target IP
+ hstack_extension_ip.addWidget(QLabel("IP"))
+ hstack_extension_ip.addWidget(textbox_extension_ip)
+ hstack_extension_ip.addStretch()
+
+ # build hstack for the extension's target port
+ hstack_extension_port.addWidget(QLabel("Port"))
+ hstack_extension_port.addWidget(spinner_extension_port)
+ hstack_extension_port.addStretch()
+
+ # build hstack for the extension's own buttons
+ hstack_extension_buttons.addWidget(button_extension_toggle)
+ hstack_extension_buttons.addWidget(button_extension_log)
+ hstack_extension_buttons.addStretch()
+
+ # add Extension's rows to its own vstack
+ vstack_extension.addLayout(hstack_extension_activity_and_label)
+ vstack_extension.addLayout(hstack_extension_ip)
+ vstack_extension.addLayout(hstack_extension_port)
+ vstack_extension.addLayout(hstack_extension_buttons)
+
+ # place OSC, Remote, and Extension columns side by side
+ hstack_osc_and_remote.addLayout(vstack_osc)
+ hstack_osc_and_remote.addLayout(vstack_remote)
+ hstack_osc_and_remote.addLayout(vstack_extension)
+
# connect signals
textbox_osc_daw_ip.textChanged.connect(update_osc_server_ip)
spinner_osc_send_port.valueChanged.connect(update_osc_server_port)
@@ -1290,11 +1449,8 @@ class MainWindow(QMainWindow):
button_layout_panel.clicked.connect(layout_panel_show_hide)
button_focus_panel.clicked.connect(focus_panel_show_hide)
- # add the combined stack to the row
- hstack_io_row.addLayout(vstack_osc)
-
- # add the combined stack to the row
- hstack_io_row.addLayout(vstack_osc)
+ # add the combined OSC + Remote stack to the row
+ hstack_io_row.addLayout(hstack_osc_and_remote)
hstack_io_row.addStretch()
main_layout.addLayout(hstack_io_row)
@@ -1437,6 +1593,18 @@ class MainWindow(QMainWindow):
button_tablet_log.clicked.connect(tablet_log_show_hide)
button_ws_log.clicked.connect(ws_log_show_hide)
+ #MARK: EXTENSION LOG CONTAINER
+ hstack_extension_log_header.addWidget(label_extension_log_title)
+ hstack_extension_log_header.addStretch()
+ hstack_extension_log_header.addWidget(button_extension_log_clear)
+ vstack_extension_log.addLayout(hstack_extension_log_header)
+ vstack_extension_log.addWidget(textedit_extension_log)
+ button_extension_log_clear.clicked.connect(lambda: textedit_extension_log.clear())
+ container_extension_log.setVisible(False)
+
+ button_extension_toggle.clicked.connect(toggle_extension_connection)
+ button_extension_log.clicked.connect(extension_log_show_hide)
+
#MARK: LAYOUT PANEL
container_layout_panel.setVisible(True)
button_layout_panel.setChecked(True)
@@ -1714,6 +1882,7 @@ class MainWindow(QMainWindow):
QTimer.singleShot(0, _auto_start_osc_server)
QTimer.singleShot(0, _auto_start_tablet_server)
+ QTimer.singleShot(0, _auto_start_extension)
def resizeEvent(self, event):
super().resizeEvent(event)
@@ -2632,6 +2801,9 @@ def save_io_config():
# server has had a chance to auto-start.
"osc_running": loaded_presets.get("io_config", {}).get("osc_running", True),
"tablet_running": loaded_presets.get("io_config", {}).get("tablet_running", True),
+ "extension_ip": textbox_extension_ip.text(),
+ "extension_port": spinner_extension_port.value(),
+ "extension_running": loaded_presets.get("io_config", {}).get("extension_running", True),
}
save_presets_file(loaded_presets)
@@ -2643,6 +2815,10 @@ def save_tablet_running_state(running):
loaded_presets.setdefault("io_config", {})["tablet_running"] = running
save_io_config()
+def save_extension_running_state(running):
+ loaded_presets.setdefault("io_config", {})["extension_running"] = running
+ save_io_config()
+
def load_io_config():
global available_ports_midi_in
global available_ports_midi_controller_in
@@ -2668,6 +2844,12 @@ def load_io_config():
send = cfg.get("osc_send_port", 0)
if send:
spinner_osc_send_port.setValue(send)
+ ext_ip = cfg.get("extension_ip", "")
+ if ext_ip:
+ textbox_extension_ip.setText(ext_ip)
+ ext_port = cfg.get("extension_port", 0)
+ if ext_port:
+ spinner_extension_port.setValue(ext_port)
def preset_build_snapshot(name):
save_transport_preset()
@@ -3452,6 +3634,55 @@ def toggle_tablet_server():
_start_tablet_server()
+def log_extension(line):
+ from datetime import datetime
+ ts = datetime.now().strftime("%H:%M:%S")
+ full = f"{ts} {line}"
+ _append_log(textedit_extension_log, full)
+
+
+def extension_log_show_hide(checked):
+ container_extension_log.setVisible(checked)
+ if checked:
+ extension_log_window_calc()
+
+
+def extension_log_window_calc():
+ geo = app_window.frameGeometry()
+ container_extension_log.setFixedWidth(360)
+ container_extension_log.setFixedHeight(geo.height())
+ container_extension_log.move(geo.right() + 1096, geo.top())
+
+
+def toggle_extension_connection():
+ if is_extension_enabled():
+ set_extension_enabled(False)
+ button_extension_toggle.setText("Connect Extension")
+ label_connection_extension.setText("Not Connected")
+ label_connection_extension.setStyleSheet("color: #888;")
+ activity_indicator_extension.setStyleSheet("color: #444; font-size: 14px;")
+ log_extension("disconnected")
+ save_extension_running_state(False)
+ else:
+ ip = textbox_extension_ip.text().strip() or "127.0.0.1"
+ port = spinner_extension_port.value()
+ set_extension_target(ip=ip, port=port)
+ set_extension_enabled(True)
+ button_extension_toggle.setText("Disconnect Extension")
+ label_connection_extension.setText(f"Connected :{port}")
+ label_connection_extension.setStyleSheet("color: #27ae60;")
+ activity_indicator_extension.setStyleSheet("color: #2ecc71; font-size: 14px;")
+ log_extension(f"connected {ip}:{port}")
+ send_to_extension(0.0) # harmless no-op nudge, just proves reachability
+ save_extension_running_state(True)
+
+
+def _auto_start_extension():
+ should_connect = loaded_presets.get("io_config", {}).get("extension_running", True)
+ if should_connect:
+ toggle_extension_connection()
+
+
def _start_tablet_server():
global ws_server
import socket as _socket
@@ -3470,8 +3701,11 @@ def _start_tablet_server():
s.close()
except Exception:
ip = "localhost"
- button_tablet_server.setText("Stop Tablet")
+ button_tablet_server.setText("Disable Remote")
label_tablet_status.setText(f"ws://{ip}:8765")
+ label_connection_remote.setText("Listening :8765")
+ label_connection_remote.setStyleSheet("color: #27ae60;")
+ activity_indicator_remote.setStyleSheet("color: #2ecc71; font-size: 14px;")
log_tablet(f"WS ws://{ip}:8765")
save_tablet_running_state(True)
if ip != "localhost":
@@ -3489,9 +3723,12 @@ def _stop_tablet_server():
if ws_server:
ws_server.stop()
ws_server = None
- button_tablet_server.setText("Start Tablet")
+ button_tablet_server.setText("Enable Remote")
save_tablet_running_state(False)
label_tablet_status.setText("")
+ label_connection_remote.setText("Not Listening")
+ label_connection_remote.setStyleSheet("color: #888;")
+ activity_indicator_remote.setStyleSheet("color: #444; font-size: 14px;")
log_tablet("server stopped")
_stop_bonjour_advertisement()
@@ -3663,17 +3900,18 @@ def _on_focus_fader_control_f(uid, value):
if f.uid != uid:
continue
value = max(0.0, min(1.0, value))
- addr = f.osc_addr_out_edit.text().strip() or "/track/volume"
slider_value = round(value * 127)
f.fader.blockSignals(True)
f.fader.setValue(slider_value)
f.fader.blockSignals(False)
f.last_sent = slider_value
f.prev_position = slider_value
- f.cc_output_lbl.setText(f._osc_output_label(slider_value))
- f.readout.setText(f"{addr} → {value:.4f}")
- send_osc_message(addr, value)
- f._send_hw_feedback(slider_value)
+ if f.output_mode == "ext":
+ f.cc_output_lbl.setText(f"Ext [{slider_value}]")
+ else:
+ f.cc_output_lbl.setText(f._osc_output_label(slider_value))
+ f.readout.setText(f"[{value:.4f}]")
+ f._emit_value(slider_value) # handles OSC vs Ext, and hw feedback
if ws_server:
ws_server.broadcast_widget_update_f(uid, value)
return
diff --git a/app-presets/presets.json b/app-presets/presets.json
index efdfd98..611de79 100644
--- a/app-presets/presets.json
+++ b/app-presets/presets.json
@@ -1,5 +1,5 @@
{
- "__last_used__": "preset5",
+ "__last_used__": "preset2",
"presets": {
"preset1": {
"faders": [
@@ -938,7 +938,10 @@
"osc_listen_port": 9001,
"osc_send_port": 8000,
"osc_running": true,
- "tablet_running": true
+ "tablet_running": true,
+ "extension_ip": "127.0.0.1",
+ "extension_port": 9124,
+ "extension_running": true
},
"focus_toggles": [
{
@@ -1257,7 +1260,7 @@
"value": 73,
"color": "Green",
"pickup": false,
- "last_sent": 62,
+ "last_sent": 73,
"osc_addr_in": "/3/volume",
"osc_addr_out": "/3/volume",
"osc_addr_name": "/3/trackname",
@@ -1270,7 +1273,8 @@
"hw_channel": 16,
"hw_out_cc": 8,
"hw_out_ch": 16,
- "touch_sense_mode": "jl_cooper"
+ "touch_sense_mode": "jl_cooper",
+ "output_mode": "osc"
},
{
"uid": "279a2c81-6548-422e-bf6d-75119c81b9c3",
@@ -1278,7 +1282,7 @@
"value": 65,
"color": "Gray",
"pickup": false,
- "last_sent": 42,
+ "last_sent": 65,
"osc_addr_in": "/3/sendlevel1",
"osc_addr_out": "/3/sendlevel1",
"osc_addr_name": "/track/name",
@@ -1291,7 +1295,30 @@
"hw_channel": 16,
"hw_out_cc": 7,
"hw_out_ch": 16,
- "touch_sense_mode": "jl_cooper"
+ "touch_sense_mode": "jl_cooper",
+ "output_mode": "osc"
+ },
+ {
+ "uid": "8de2c4e9-2f05-4a71-a610-d712ee69d1a1",
+ "label": "Envelope Adjust",
+ "value": 45,
+ "color": "Gray",
+ "pickup": false,
+ "last_sent": 45,
+ "osc_addr_in": "/track/volume",
+ "osc_addr_out": "/track/volume",
+ "osc_addr_name": "/track/name",
+ "zone": null,
+ "center_index": null,
+ "zone_index": null,
+ "hardware_enabled": false,
+ "feedback_enabled": false,
+ "hw_cc": null,
+ "hw_channel": null,
+ "hw_out_cc": 7,
+ "hw_out_ch": 1,
+ "touch_sense_mode": "off",
+ "output_mode": "ext"
}
]
}
\ No newline at end of file
diff --git a/extension-reaper-macos/build.sh b/extension-reaper-macos/build.sh
index 72893f3..9c7add5 100755
--- a/extension-reaper-macos/build.sh
+++ b/extension-reaper-macos/build.sh
@@ -13,6 +13,12 @@ clang++ \
-Ivendor/reaper-sdk/sdk \
-Ivendor/reaper-sdk/WDL \
-o "$OUT" \
- src/main.cpp
+ src/main.cpp \
+ src/tracking.cpp \
+ src/socket.cpp
echo "Built $OUT"
+
+DEST="$HOME/Library/Application Support/REAPER/UserPlugins/$OUT"
+cp "$OUT" "$DEST"
+echo "Copied to $DEST (restart REAPER to reload)"
diff --git a/extension-reaper-macos/src/main.cpp b/extension-reaper-macos/src/main.cpp
index 2adca2f..3186f99 100644
--- a/extension-reaper-macos/src/main.cpp
+++ b/extension-reaper-macos/src/main.cpp
@@ -1,50 +1,112 @@
// extension-reaper-macos — bare-minimum REAPER extension.
//
-// Verifies the extension loads and is recognized by REAPER: prints a
-// confirmation line to the REAPER console on load, and registers one test
-// action ("extension-reaper-macos: Hello") in the Actions list.
+// Loads, registers one test action ("extension-reaper-macos: Hello"), and
+// prints a confirmation to the REAPER console.
+//
+// Top-level story:
+// - We tell the compiler which REAPER functions we need a box for.
+// - REAPER loads our .dylib and calls our one required function, once.
+// - We check we're actually loading, not unloading, and the version matches.
+// - We fill our function box(es) with their real address.
+// - We describe a new action and hand it to REAPER — it creates it, gives
+// us back a number.
+// - We subscribe our own function to REAPER's "every action, any trigger"
+// stream.
+// - We print "loaded successfully" — proof of everything up to that point,
+// but NOT proof the action/subscription actually works.
+// - Setup's done. We now just sit in memory, doing nothing.
+// - Later, any action anywhere in REAPER (click, key, MIDI, OSC) calls our
+// subscribed function.
+// - It checks if the action was ours. If yes, react. If no, ignore.
+// We tell the compiler which REAPER functions we need a box for. This file
+// owns the real storage (REAPERAPI_IMPLEMENT), so this list has to cover
+// everything used anywhere in the project, including tracking.cpp.
#define REAPERAPI_MINIMAL
#define REAPERAPI_WANT_ShowConsoleMsg
+#define REAPERAPI_WANT_CountAutomationItems
+#define REAPERAPI_WANT_GetSetAutomationItemInfo
+#define REAPERAPI_WANT_CountTracks
+#define REAPERAPI_WANT_GetTrack
+#define REAPERAPI_WANT_CountTrackEnvelopes
+#define REAPERAPI_WANT_GetTrackEnvelope
#define REAPERAPI_IMPLEMENT
#include "reaper_plugin.h"
#include "reaper_plugin_functions.h"
+#include "tracking.h"
+#include "socket.h"
-static int g_hello_cmd_id = 0;
+#include
-static bool HookCommand2(KbdSectionInfo *sec, int command, int val, int val2, int relmode, HWND hwnd)
+static int action1_id = 0;
+static const char *kAction1IdStr = "EXTENSION_REAPER_MACOS_HELLO";
+static const char *kAction1Name = "Hello Action List Display Name";
+
+// Testing hookcommand2 again (REAPER's docs specifically recommend it for
+// custom_action-registered actions), this time with debug prints kept in.
+static bool Action1(KbdSectionInfo *sec, int command, int val, int val2, int relmode, HWND hwnd)
{
- if (command == g_hello_cmd_id)
+ // Unconditional — proves whether this callback is being reached at all,
+ // and for which command IDs, regardless of whether it's ours.
+ char buf[128];
+ snprintf(buf, sizeof(buf), "[extension-reaper-macos] Action1 called: command=%d action1_id=%d\n", command, action1_id);
+ ShowConsoleMsg(buf);
+
+ if (command == action1_id)
{
ShowConsoleMsg("[extension-reaper-macos] Hello action triggered\n");
return true;
}
+
+ ShowConsoleMsg("[extension-reaper-macos] ignored — not ours\n");
return false;
}
+// REAPER loads our .dylib and calls this once.
extern "C" REAPER_PLUGIN_DLL_EXPORT int REAPER_PLUGIN_ENTRYPOINT(REAPER_PLUGIN_HINSTANCE hInstance, reaper_plugin_info_t *rec)
{
+ // Check we're actually loading, not unloading, and the version matches.
if (!rec)
- return 0; // REAPER is unloading us
-
+ return 0;
if (rec->caller_version != REAPER_PLUGIN_VERSION)
return 0;
+ // Fill our function box(es) with their real address.
if (REAPERAPI_LoadAPI(rec->GetFunc) != 0)
- return 0; // a required API function was missing
+ return 0;
- custom_action_register_t action = {
- 0, // main section
- "EXTENSION_REAPER_MACOS_HELLO",
- "extension-reaper-macos: Hello",
+ // Describe a new action and hand it to REAPER — it creates it, gives us
+ // back a number, which we save.
+ custom_action_register_t actionDescription = {
+ 0,
+ kAction1IdStr,
+ kAction1Name,
NULL,
};
- g_hello_cmd_id = rec->Register("custom_action", &action);
+ action1_id = rec->Register("custom_action", &actionDescription);
- rec->Register("hookcommand2", (void *)HookCommand2);
+ // Verify registration actually succeeded — Register returns 0 on failure,
+ // and we've never checked that until now.
+ char buf[128];
+ snprintf(buf, sizeof(buf), "[extension-reaper-macos] action1_id = %d\n", action1_id);
+ ShowConsoleMsg(buf);
+ // Subscribe Action1 via hookcommand2 — REAPER's documented pairing for
+ // custom_action-registered actions specifically.
+ rec->Register("hookcommand2", (void *)Action1);
+
+ // Envelope/automation-item polling now lives in tracking.cpp.
+ RegisterTracking(rec);
+
+ // Basic UDP socket listener lives in socket.cpp.
+ RegisterSocket(rec);
+
+ // Print "loaded successfully" — proof of everything above, but NOT proof
+ // the action/subscription actually works.
ShowConsoleMsg("[extension-reaper-macos] loaded successfully\n");
+ // Setup's done. Return 1 = "keep me loaded." We now just sit in memory,
+ // doing nothing, until Action1 gets called later.
return 1;
}
diff --git a/extension-reaper-macos/src/socket.cpp b/extension-reaper-macos/src/socket.cpp
new file mode 100644
index 0000000..06501d5
--- /dev/null
+++ b/extension-reaper-macos/src/socket.cpp
@@ -0,0 +1,94 @@
+// A basic UDP socket listener. Opens a local port and polls it
+// non-blockingly via REAPER's "timer" callback (same mechanism
+// tracking.cpp uses — each Register("timer", ...) call adds its own
+// independent subscriber, so this runs alongside tracking.cpp's OnTimer,
+// not instead of it). Non-blocking means this never stalls REAPER's main
+// thread waiting for network data that may never arrive.
+//
+// Incoming messages are parsed as a plain number (e.g. "0.05" or "-0.05")
+// and applied as a delta to the currently-selected automation item's
+// baseline, via the same NudgeSelectedBaseline tracking.cpp's manual
+// actions already use.
+
+// No REAPERAPI_IMPLEMENT here — main.cpp owns the real storage.
+#define REAPERAPI_MINIMAL
+#define REAPERAPI_WANT_ShowConsoleMsg
+
+#include "reaper_plugin.h"
+#include "reaper_plugin_functions.h"
+#include "socket.h"
+#include "tracking.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+static const int kListenPort = 9124;
+static int listen_fd = -1;
+
+static void OnSocketTimer()
+{
+ if (listen_fd < 0)
+ return;
+
+ // Drain every pending packet each tick, instead of one recvfrom() per
+ // tick. REAPER's timer only fires ~30x/second-ish, so if messages arrive
+ // faster than that (a fast fader drag easily does), a one-shot read
+ // falls further and further behind as packets queue up in the OS buffer.
+ // Since our protocol is deltas, summing everything pending into one net
+ // value and applying it once is equivalent to applying each
+ // individually — just one REAPER API call instead of many.
+ double total_delta = 0.0;
+ char data[256];
+ ssize_t n;
+ while ((n = recvfrom(listen_fd, data, sizeof(data) - 1, 0, NULL, NULL)) > 0)
+ {
+ data[n] = '\0';
+
+ // Note: atof() returns 0.0 both for "actually parsed as zero" and for
+ // "couldn't parse this at all" — can't tell those apart from the
+ // return value alone. Fine for now since we control what sends here.
+ total_delta += atof(data);
+ }
+
+ if (total_delta != 0.0)
+ NudgeSelectedBaseline(total_delta);
+}
+
+void RegisterSocket(reaper_plugin_info_t *rec)
+{
+ listen_fd = socket(AF_INET, SOCK_DGRAM, 0);
+ if (listen_fd < 0)
+ {
+ ShowConsoleMsg("[extension-reaper-macos] socket() failed\n");
+ return;
+ }
+
+ // Non-blocking — recvfrom() returns immediately if nothing's arrived,
+ // instead of stalling REAPER's main thread waiting for data.
+ fcntl(listen_fd, F_SETFL, O_NONBLOCK);
+
+ sockaddr_in addr;
+ memset(&addr, 0, sizeof(addr));
+ addr.sin_family = AF_INET;
+ addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); // localhost only, for now
+ addr.sin_port = htons(kListenPort);
+
+ if (bind(listen_fd, (sockaddr *)&addr, sizeof(addr)) < 0)
+ {
+ ShowConsoleMsg("[extension-reaper-macos] socket bind() failed\n");
+ close(listen_fd);
+ listen_fd = -1;
+ return;
+ }
+
+ char buf[128];
+ snprintf(buf, sizeof(buf), "[extension-reaper-macos] socket listening on 127.0.0.1:%d\n", kListenPort);
+ ShowConsoleMsg(buf);
+
+ rec->Register("timer", (void *)OnSocketTimer);
+}
diff --git a/extension-reaper-macos/src/socket.h b/extension-reaper-macos/src/socket.h
new file mode 100644
index 0000000..c54be29
--- /dev/null
+++ b/extension-reaper-macos/src/socket.h
@@ -0,0 +1,14 @@
+// A basic UDP socket listener for extension-reaper-macos — the eventual
+// entry point for the desktop app to spray fader/control data at REAPER.
+
+#ifndef EXTENSION_REAPER_MACOS_SOCKET_H
+#define EXTENSION_REAPER_MACOS_SOCKET_H
+
+#include "reaper_plugin.h"
+
+// Opens a UDP socket and registers a REAPER timer to poll it
+// non-blockingly. Call once from the entrypoint, after REAPERAPI_LoadAPI
+// has succeeded.
+void RegisterSocket(reaper_plugin_info_t *rec);
+
+#endif
diff --git a/extension-reaper-macos/src/tracking.cpp b/extension-reaper-macos/src/tracking.cpp
new file mode 100644
index 0000000..f755e36
--- /dev/null
+++ b/extension-reaper-macos/src/tracking.cpp
@@ -0,0 +1,156 @@
+// Tracks REAPER's currently selected automation item(s), purely by
+// polling. REAPER doesn't tell us when selection changes, so we check
+// repeatedly via a "timer" callback and only print when something's new.
+//
+// Also registers two test actions (nudge baseline up/down) to prove we can
+// WRITE to an automation item, not just read it — same pattern as Hello in
+// main.cpp: custom_action to register, hookcommand2 to react when it runs.
+
+// No REAPERAPI_IMPLEMENT here — main.cpp owns the real storage for these
+// function boxes (it's the one .cpp file that defines IMPLEMENT). This file
+// just borrows them via extern declarations, same names, same addresses.
+#define REAPERAPI_MINIMAL
+#define REAPERAPI_WANT_ShowConsoleMsg
+#define REAPERAPI_WANT_CountAutomationItems
+#define REAPERAPI_WANT_GetSetAutomationItemInfo
+#define REAPERAPI_WANT_CountTracks
+#define REAPERAPI_WANT_GetTrack
+#define REAPERAPI_WANT_CountTrackEnvelopes
+#define REAPERAPI_WANT_GetTrackEnvelope
+
+#include "reaper_plugin.h"
+#include "reaper_plugin_functions.h"
+#include "tracking.h"
+
+#include
+#include
+
+static int nudge_up_id = 0;
+static int nudge_down_id = 0;
+static const double kNudgeAmount = 0.05;
+
+struct SelectedItem
+{
+ TrackEnvelope *env;
+ int idx;
+ bool operator==(const SelectedItem &other) const
+ {
+ return env == other.env && idx == other.idx;
+ }
+};
+
+// Scans every envelope on every track in the project, collecting every
+// automation item currently marked selected (D_UISEL) — regardless of
+// which track/envelope currently has UI focus. That's what lets this work
+// across multiple tracks at once, and also what makes a clip keep being
+// targeted after you've clicked onto a different track: we're not asking
+// REAPER "what's focused right now," we're checking the actual selection
+// flag on every item, everywhere, every time.
+static std::vector FindAllSelectedItems()
+{
+ std::vector result;
+
+ int track_count = CountTracks(NULL);
+ for (int t = 0; t < track_count; t++)
+ {
+ MediaTrack *track = GetTrack(NULL, t);
+ if (!track)
+ continue;
+
+ int env_count = CountTrackEnvelopes(track);
+ for (int e = 0; e < env_count; e++)
+ {
+ TrackEnvelope *env = GetTrackEnvelope(track, e);
+ if (!env)
+ continue;
+
+ int item_count = CountAutomationItems(env);
+ for (int i = 0; i < item_count; i++)
+ {
+ if (GetSetAutomationItemInfo(env, i, "D_UISEL", 0, false) != 0)
+ result.push_back({env, i});
+ }
+ }
+ }
+ return result;
+}
+
+static std::vector last_printed_items;
+
+// REAPER calls this automatically, many times per second, no matter what
+// the user is doing. Prints only when the selected set actually changes.
+static void OnTimer()
+{
+ std::vector items = FindAllSelectedItems();
+
+ if (items != last_printed_items)
+ {
+ last_printed_items = items;
+
+ char buf[128];
+ snprintf(buf, sizeof(buf), "[extension-reaper-macos] %zu item(s) selected\n", items.size());
+ ShowConsoleMsg(buf);
+ }
+}
+
+// Shared by the manual nudge actions (below) and socket.cpp's listener —
+// one path, two ways to trigger it. Applies the same delta to every
+// currently-selected item, anywhere in the project, each clamped
+// independently.
+void NudgeSelectedBaseline(double delta)
+{
+ std::vector items = FindAllSelectedItems();
+ if (items.empty())
+ {
+ ShowConsoleMsg("[extension-reaper-macos] nudge: nothing selected\n");
+ return;
+ }
+
+ for (const SelectedItem &item : items)
+ {
+ double baseline = GetSetAutomationItemInfo(item.env, item.idx, "D_BASELINE", 0, false);
+ double newBaseline = baseline + delta;
+ if (newBaseline < 0.0) newBaseline = 0.0; // D_BASELINE's valid range is [0,1]
+ if (newBaseline > 1.0) newBaseline = 1.0;
+ GetSetAutomationItemInfo(item.env, item.idx, "D_BASELINE", newBaseline, true);
+ }
+ // No console log here — this runs on every fader move, and a GUI
+ // text-widget update per call was itself a real latency cost.
+}
+
+// Called for every action, any trigger source. Checks if it was one of our
+// two nudge actions; if so, calls the shared NudgeSelectedBaseline above.
+static bool NudgeAction(KbdSectionInfo *sec, int command, int val, int val2, int relmode, HWND hwnd)
+{
+ if (command == nudge_up_id)
+ NudgeSelectedBaseline(kNudgeAmount);
+ else if (command == nudge_down_id)
+ NudgeSelectedBaseline(-kNudgeAmount);
+ else
+ return false; // not ours
+
+ return true;
+}
+
+void RegisterTracking(reaper_plugin_info_t *rec)
+{
+ rec->Register("timer", (void *)OnTimer);
+
+ custom_action_register_t nudge_up_desc = {
+ 0,
+ "EXTENSION_REAPER_MACOS_NUDGE_UP",
+ "extension-reaper-macos: Nudge baseline up",
+ NULL,
+ };
+ nudge_up_id = rec->Register("custom_action", &nudge_up_desc);
+
+ custom_action_register_t nudge_down_desc = {
+ 0,
+ "EXTENSION_REAPER_MACOS_NUDGE_DOWN",
+ "extension-reaper-macos: Nudge baseline down",
+ NULL,
+ };
+ nudge_down_id = rec->Register("custom_action", &nudge_down_desc);
+
+ rec->Register("hookcommand2", (void *)NudgeAction);
+}
diff --git a/extension-reaper-macos/src/tracking.h b/extension-reaper-macos/src/tracking.h
new file mode 100644
index 0000000..575b77c
--- /dev/null
+++ b/extension-reaper-macos/src/tracking.h
@@ -0,0 +1,17 @@
+// Polls REAPER for the currently selected envelope + automation item, since
+// REAPER has no "selection changed" notification to hook directly.
+
+#ifndef EXTENSION_REAPER_MACOS_TRACKING_H
+#define EXTENSION_REAPER_MACOS_TRACKING_H
+
+#include "reaper_plugin.h"
+
+// Call once from the entrypoint, after REAPERAPI_LoadAPI has succeeded.
+// Registers our polling function with REAPER's "timer" callback.
+void RegisterTracking(reaper_plugin_info_t *rec);
+
+// Applies delta to D_BASELINE on the currently-selected automation item (if
+// any). Shared by the manual nudge actions and socket.cpp's listener.
+void NudgeSelectedBaseline(double delta);
+
+#endif