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