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
+7 -1
View File
@@ -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)"
+77 -15
View File
@@ -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 <cstdio>
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;
}
+94
View File
@@ -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 <cstdio>
#include <cstdlib>
#include <cstring>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <fcntl.h>
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);
}
+14
View File
@@ -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
+156
View File
@@ -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 <cstdio>
#include <vector>
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<SelectedItem> FindAllSelectedItems()
{
std::vector<SelectedItem> 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<SelectedItem> 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<SelectedItem> 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<SelectedItem> 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);
}
+17
View File
@@ -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