authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-07-04 22:59:46-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-07-05 00:39:04-07:00
logefa2473ccc8ba2de27d5d8a7a0f9e62c3968deb5
tree9d172b65294e09f251ae3a874be2aac14f1e4eae
parentcd9bcc4e3bf81e6d9045629d00f3f033d67874af
signaturebadge-check Signed by SSH key SHA256:xbd+BjjhyBfwk7GVoURf9Yx0gzDerHbvYv7SddNWmAs

feat: reaper stuff


5 files changed, 530 insertions(+), 60 deletions(-)

control/config/reaper.ts+12-16
......@@ -1,20 +1,13 @@
11import * as config from "#config";
22import { lucide, mdi, stack, timeSignature, txt } from "@clo/clover-control/icons";
33import { Reaper } from "@clo/clover-control/Reaper";
4import { signal } from "@clo/clover-control/signals";
4import { effect } from "@clo/clover-control/signals";
55
66export default config.forApp(
77 "com.cockos.reaper",
88 ({ keypad, dialpad, mac }) => {
99 const reaper = new Reaper();
10
11 // Live project state — faces that read these re-render themselves on change.
12 const bpm = signal(120);
13 const timeSig = signal("4/4");
14 reaper.on("transport", (t) => {
15 bpm.set(Math.round(t.tempo));
16 timeSig.set(t.timeSignature);
17 });
10 const { tempo, timeSignature: timeSig, playing } = reaper.state;
1811
1912 const addInstrument = keypad.menu((menu) => {
2013 menu.key("up-left", txt("KK"), () => {
......@@ -47,14 +40,14 @@ export default config.forApp(
4740 });
4841 });
4942
50 keypad.key("up-left", mdi("metronome"), () => {
43 keypad.key("up-left", () => mdi("metronome").fg(reaper.state.metronomeOn() ? "teal" : "white"), () => {
5144 reaper.runAction("options-toggle-metronome");
5245 });
5346 keypad.key("up", () => timeSignature(timeSig()), () => {
54 reaper.runAction("file-project-settings");
47 reaper.runScript("insert_time_signature");
5548 });
56 keypad.key("up-right", () => stack(bpm(), "BPM"), () => {
57 reaper.runAction("tempo-increase-current-project-tempo-01-bpm");
49 keypad.key("up-right", () => stack(Math.round(tempo()), "BPM"), () => {
50 reaper.runAction("tempo-envelope-insert-tempo-time-signature-change-marker-at-edit-cursor");
5851 });
5952 keypad.key("left", lucide("Plus"), () => {
6053 addInstrument.open();
......@@ -62,12 +55,15 @@ export default config.forApp(
6255 keypad.key("down-left", lucide("Mic").fg("red"), () => {
6356 reaper.runAction("transport-record");
6457 });
65 keypad.key("down-right", lucide("Play"), () => {
58 keypad.key("down-right", () => lucide(playing() ? "Pause" : "Play"), () => {
6659 reaper.runAction("transport-play-stop");
6760 });
61 keypad.key("center", () => mdi("magnet").fg(reaper.state.snapOn() ? "teal" : "white"), () => {
62 reaper.runAction("options-toggle-snapping");
63 });
6864
69 reaper.on("transport", (transport) => {
70 recording.active = transport.recording;
65 effect(() => {
66 recording.active = reaper.state.recording();
7167 });
7268
7369 dialpad.on("rotate", (delta) => {
control/config/reaper/scripts/clover_feedback.lua+125-22
......@@ -1,10 +1,20 @@
1-- Clover live feedback: continuously write the project's tempo and time
2-- signature to state.json (next to this script) so the Clover Node process can
3-- watch the file and update the keypad. Re-schedules itself via reaper.defer,
4-- writing only when a value actually changes.
1-- Clover live feedback: write a snapshot of REAPER/project state to state.json
2-- (next to this script) so the Clover Node process can watch the file and drive
3-- the keypad. Re-schedules itself via reaper.defer, but only does real work at
4-- ~10 Hz and only writes (via an atomic temp-file rename) when the snapshot
5-- actually changes — so an idle or merely-playing project writes nothing.
56--
6-- REAPER's OSC has a tempo token but no time-signature feedback, so we read both
7-- here in one place (reaper.TimeMap_GetTimeSigAtTime returns num, denom, tempo).
7-- This carries only *low-frequency* state: things a human changes (tempo, time
8-- signature, track selection, master volume, action toggle states) plus the
9-- current bar number. It deliberately does NOT carry the continuous playhead —
10-- that high-frequency position data belongs on OSC (event-driven, no disk), not a
11-- polled file. Bar position is emitted by edge detection: we compute the measure
12-- each tick but only write when it changes, so during playback the file updates
13-- once per measure (on the downbeat), not on every timestamp.
14--
15-- The set of action command IDs whose toggle state we report is read from
16-- watch.json, a list the Node side maintains, so new toggle signals need no edit
17-- to this script.
818
919-- Avoid stacking multiple defer loops if the script gets launched again (e.g. a
1020-- Clover restart while REAPER keeps running).
......@@ -21,31 +31,124 @@ local script_path = source:match("^@(.+)$")
2131local script_dir = script_path:match("^(.*)[/\\].-$")
2232local sep = package.config:sub(1, 1)
2333local state_path = script_dir .. sep .. "state.json"
34local watch_path = script_dir .. sep .. "watch.json"
2435
25local last = nil
36local POLL_INTERVAL = 0.1 -- seconds between real snapshots (~10 Hz)
37local WATCH_INTERVAL = 1.0 -- seconds between re-reads of the watch list
2638
27local function snapshot()
28 local position
29 if reaper.GetPlayState() > 0 then
30 position = reaper.GetPlayPosition()
31 else
32 position = reaper.GetCursorPosition()
39local function json_string(value)
40 value = tostring(value or "")
41 value = value:gsub("\\", "\\\\"):gsub('"', '\\"')
42 :gsub("\n", "\\n"):gsub("\r", "\\r"):gsub("\t", "\\t")
43 return '"' .. value .. '"'
44end
45
46-- Cache the watched command IDs; the Node side rewrites watch.json only when a
47-- signal is added, so re-reading it once a second is plenty responsive.
48local watch_ids = {}
49local watch_read_at = nil
50
51local function refresh_watch_ids(now)
52 if watch_read_at and (now - watch_read_at) < WATCH_INTERVAL then return end
53 watch_read_at = now
54 local ids = {}
55 local file = io.open(watch_path, "r")
56 if file then
57 local content = file:read("*a")
58 file:close()
59 for id in content:gmatch("%d+") do
60 ids[#ids + 1] = tonumber(id)
61 end
3362 end
63 watch_ids = ids
64end
65
66local function toggles_json()
67 local parts = {}
68 for _, id in ipairs(watch_ids) do
69 -- GetToggleCommandState: -1 unknown, 0 off, 1 on.
70 local state = reaper.GetToggleCommandState(id)
71 parts[#parts + 1] = string.format('"%d":%d', id, state == 1 and 1 or 0)
72 end
73 return "{" .. table.concat(parts, ",") .. "}"
74end
3475
35 local num, denom, tempo = reaper.TimeMap_GetTimeSigAtTime(0, position)
76local function snapshot()
77 local edit_pos = reaper.GetCursorPosition() -- moves on click, not during playback
78 -- Follow the playhead while transport is rolling, else the edit cursor. Only
79 -- the *measure* derived from this is emitted, so it changes at bar boundaries.
80 local pos = reaper.GetPlayState() > 0 and reaper.GetPlayPosition() or edit_pos
81 local _, measure_index = reaper.TimeMap2_timeToBeats(0, pos)
82 local measure = math.floor(measure_index) + 1 -- TimeMap2 measures are 0-based
83
84 local num, denom = reaper.TimeMap_GetTimeSigAtTime(0, pos)
3685 num = math.floor(num + 0.5)
3786 denom = math.floor(denom + 0.5)
38 return string.format('{"tempo":%.3f,"timesig":"%d/%d"}', tempo, num, denom)
87 local tempo = reaper.Master_GetTempo()
88
89 local repeat_on = reaper.GetSetRepeat(-1)
90 local proj_len = reaper.GetProjectLength(0)
91
92 local sel_start, sel_end = reaper.GetSet_LoopTimeRange(false, false, 0, 0, false)
93 local sel_active = sel_end > sel_start
94
95 local track_count = reaper.CountTracks(0)
96 local sel_track_count = reaper.CountSelectedTracks(0)
97 local sel_name, sel_index = "", 0
98 local sel_track = reaper.GetSelectedTrack(0, 0)
99 if sel_track then
100 local _, name = reaper.GetTrackName(sel_track)
101 sel_name = name or ""
102 sel_index = math.floor(reaper.GetMediaTrackInfo_Value(sel_track, "IP_TRACKNUMBER") + 0.5)
103 end
104
105 local master = reaper.GetMasterTrack(0)
106 local vol = reaper.GetMediaTrackInfo_Value(master, "D_VOL")
107 local vol_db = vol > 0 and (20 * math.log(vol) / math.log(10)) or -150
108
109 local proj_name = reaper.GetProjectName(0, "")
110 local dirty = reaper.IsProjectDirty(0)
111
112 return "{"
113 .. string.format('"tempo":%.3f', tempo)
114 .. string.format(',"timesig":"%d/%d"', num, denom)
115 .. string.format(',"repeat":%d', repeat_on)
116 .. string.format(',"measure":%d', measure)
117 .. string.format(',"editCursor":%.3f', edit_pos)
118 .. string.format(',"projectLength":%.3f', proj_len)
119 .. string.format(',"timeSelStart":%.3f', sel_start)
120 .. string.format(',"timeSelEnd":%.3f', sel_end)
121 .. string.format(',"timeSelActive":%d', sel_active and 1 or 0)
122 .. string.format(',"trackCount":%d', track_count)
123 .. string.format(',"selTrackCount":%d', sel_track_count)
124 .. ',"selTrackName":' .. json_string(sel_name)
125 .. string.format(',"selTrackIndex":%d', sel_index)
126 .. string.format(',"masterVolDb":%.2f', vol_db)
127 .. ',"projectName":' .. json_string(proj_name)
128 .. string.format(',"projectDirty":%d', dirty)
129 .. ',"toggles":' .. toggles_json()
130 .. "}"
39131end
40132
133local last = nil
134local last_run = nil
135
41136local function poll()
42 local snap = snapshot()
43 if snap ~= last then
44 last = snap
45 local file = io.open(state_path, "w")
46 if file then
47 file:write(snap)
48 file:close()
137 local now = reaper.time_precise()
138 -- Defer runs at UI framerate (~30 Hz); only do real work every POLL_INTERVAL.
139 if not last_run or (now - last_run) >= POLL_INTERVAL then
140 last_run = now
141 refresh_watch_ids(now)
142 local ok, snap = pcall(snapshot)
143 if ok and snap ~= last then
144 last = snap
145 local tmp = state_path .. ".tmp"
146 local file = io.open(tmp, "w")
147 if file then
148 file:write(snap)
149 file:close()
150 os.rename(tmp, state_path) -- atomic swap so readers never see a torn write
151 end
49152 end
50153 end
51154 reaper.defer(poll)
control/config/reaper/scripts/insert_time_signature.lua created+24
......@@ -0,0 +1,24 @@
1-- Insert a time-signature change at the edit cursor, then open REAPER's native
2-- tempo/time-signature dialog to edit it.
3--
4-- The bare Shift+C action (40256) opens that dialog with the "Time signature"
5-- box UNchecked, so you have to tick it every time. Pre-inserting a marker that
6-- already carries a time signature means the dialog opens in EDIT mode with the
7-- box already checked (and tempo left alone) — the effect we actually want.
8
9local proj = 0
10local cursor = reaper.GetCursorPositionEx(proj)
11
12-- Inherit the time signature currently in effect so we edit it, not clobber it.
13local num, denom = reaper.TimeMap_GetTimeSigAtTime(proj, cursor)
14
15reaper.Undo_BeginBlock()
16-- ptidx=-1 insert new | timepos=cursor | measure/beat=-1 | bpm=-1 leave tempo as-is
17reaper.SetTempoTimeSigMarker(proj, -1, cursor, -1, -1, -1, num, denom, false)
18reaper.Undo_EndBlock("Insert time signature marker", -1)
19reaper.UpdateTimeline()
20
21-- 40256 = "Tempo envelope: Insert tempo/time signature change marker at edit
22-- cursor" (the action bound to Shift+C). With a marker already at the cursor it
23-- opens in edit mode, time-signature enabled.
24reaper.Main_OnCommand(40256, 0)
control/src/Reaper.ts+353-17
......@@ -9,6 +9,7 @@ import process from "node:process";
99import { fileURLToPath } from "node:url";
1010import { promisify } from "node:util";
1111import { REAPER_ACTIONS, type ReaperActionId } from "./Reaper/actions.ts";
12import { type ReadonlySignal, signal, type Signal } from "./signals.ts";
1213const console = log.scoped("reaper");
1314
1415export type { ReaperActionId } from "./Reaper/actions.ts";
......@@ -37,6 +38,108 @@ export interface ReaperTransportState {
3738 source: "osc" | "optimistic";
3839}
3940
41/** Writable per-field signals mirroring {@link ReaperTransportState} (minus `readAtMs`). */
42type ReaperStateSignals = {
43 [K in Exclude<keyof ReaperTransportState, "readAtMs">]: Signal<
44 ReaperTransportState[K]
45 >;
46};
47
48/**
49 * Extra live project state that OSC can't report — sourced from the feedback Lua
50 * script (see config/reaper/scripts/clover_feedback.lua). Add a field here, add
51 * the matching row to {@link EXTRA_FIELDS}, and have the Lua script emit the same
52 * JSON key — that's the whole recipe for a new signal.
53 */
54export interface ReaperExtraState {
55 /** Current bar number (1-based) — ticks over on each downbeat during playback. */
56 currentMeasure: number;
57 /** Edit-cursor position in seconds. */
58 editCursorSeconds: number;
59 /** Total project length in seconds. */
60 projectLengthSeconds: number;
61 /** Time/loop selection start in seconds. */
62 timeSelectionStart: number;
63 /** Time/loop selection end in seconds. */
64 timeSelectionEnd: number;
65 /** Whether a non-empty time/loop selection exists. */
66 timeSelectionActive: boolean;
67 /** Number of tracks in the project. */
68 trackCount: number;
69 /** Number of currently selected tracks. */
70 selectedTrackCount: number;
71 /** Name of the first selected track (empty if none). */
72 selectedTrackName: string;
73 /** 1-based index of the first selected track (0 if none). */
74 selectedTrackIndex: number;
75 /** Master track volume in dB. */
76 masterVolumeDb: number;
77 /** Project file name (empty for an unsaved project). */
78 projectName: string;
79 /** Whether the project has unsaved changes. */
80 projectDirty: boolean;
81}
82
83type ReaperExtraSignals = {
84 [K in keyof ReaperExtraState]: Signal<ReaperExtraState[K]>;
85};
86
87/**
88 * Live project state as fine-grained signals. Reading one inside a reactive
89 * scope (an `effect`, `computed`, or keypad face thunk) subscribes to it, so the
90 * scope re-runs whenever just that field changes. This is the reactive twin of
91 * the {@link Reaper.transport} snapshot / `"transport"` event.
92 *
93 * Beyond the transport + {@link ReaperExtraState} fields, `toggle(actionId)`
94 * returns a signal for the on/off state of *any* toggleable REAPER action — the
95 * "infinite" escape hatch. A few common ones are pre-named for convenience.
96 */
97export type ReaperState =
98 & { readonly [K in keyof ReaperStateSignals]: ReadonlySignal<ReaperTransportState[K]> }
99 & { readonly [K in keyof ReaperExtraState]: ReadonlySignal<ReaperExtraState[K]> }
100 & {
101 /** Whether the metronome is enabled (`options-toggle-metronome`). */
102 readonly metronomeOn: ReadonlySignal<boolean>;
103 /** Whether snapping is enabled (`options-toggle-snapping`). */
104 readonly snapOn: ReadonlySignal<boolean>;
105 /** Whether pre-roll before playback is enabled. */
106 readonly preRollOnPlay: ReadonlySignal<boolean>;
107 /** Whether pre-roll before recording is enabled. */
108 readonly preRollOnRecord: ReadonlySignal<boolean>;
109 /**
110 * A signal for any action's toggle (on/off) state. The first call for an
111 * action starts watching it (REAPER reports it on the next feedback tick, so
112 * it may read `false` briefly). Repeated calls return the same signal.
113 */
114 toggle(actionId: ReaperActionId): ReadonlySignal<boolean>;
115 };
116
117/** Maps each {@link ReaperExtraState} field to its JSON key + how to coerce it. */
118const EXTRA_FIELDS: ReadonlyArray<
119 readonly [keyof ReaperExtraState, string, "number" | "string" | "boolean"]
120> = [
121 ["currentMeasure", "measure", "number"],
122 ["editCursorSeconds", "editCursor", "number"],
123 ["projectLengthSeconds", "projectLength", "number"],
124 ["timeSelectionStart", "timeSelStart", "number"],
125 ["timeSelectionEnd", "timeSelEnd", "number"],
126 ["timeSelectionActive", "timeSelActive", "boolean"],
127 ["trackCount", "trackCount", "number"],
128 ["selectedTrackCount", "selTrackCount", "number"],
129 ["selectedTrackName", "selTrackName", "string"],
130 ["selectedTrackIndex", "selTrackIndex", "number"],
131 ["masterVolumeDb", "masterVolDb", "number"],
132 ["projectName", "projectName", "string"],
133 ["projectDirty", "projectDirty", "boolean"],
134];
135
136/** A parsed feedback snapshot, split by how each part is applied. */
137interface FeedbackSnapshot {
138 transport: ReaperTransportPatch;
139 extras: Partial<ReaperExtraState>;
140 toggles: Map<number, boolean>;
141}
142
40143type ReaperScriptName = string;
41144
42145type OscScalar = number | string | boolean;
......@@ -105,7 +208,17 @@ const REAPER_SCRIPT_TARGET_DIR = process.env.REAPER_SCRIPTS_DIR
105208const REAPER_FEEDBACK_DIR = join(REAPER_SCRIPT_TARGET_DIR, "scripts");
106209const REAPER_FEEDBACK_FILE = "state.json";
107210const REAPER_FEEDBACK_STATE_PATH = join(REAPER_FEEDBACK_DIR, REAPER_FEEDBACK_FILE);
211// The Node side writes the set of action command IDs whose toggle state it wants
212// reported here; the feedback script reads it each tick. See #writeWatchList.
213const REAPER_WATCH_STATE_PATH = join(REAPER_FEEDBACK_DIR, "watch.json");
108214const REAPER_FEEDBACK_SCRIPT = "clover_feedback";
215// fs.watch on macOS (FSEvents) coalesces the feedback script's temp-write +
216// atomic rename and can drop or mislabel the resulting event, so a change to a
217// file-only field (tempo, time signature) can go unseen until the next event
218// that happens to be delivered cleanly. Poll the state file as a reliability
219// backstop; reads are idempotent and the signals dedupe, so an unchanged poll
220// costs a readFile + JSON.parse and nothing else.
221const REAPER_FEEDBACK_POLL_INTERVAL_MS = 250;
109222const REAPER_OSC_TARGET_DIR = process.env.REAPER_OSC_DIR
110223 ?? DEFAULT_REAPER_OSC_TARGET_DIR;
111224const OSC_PATTERN_FILE = "CloverAutomation.ReaperOSC";
......@@ -148,6 +261,14 @@ const execFileAsync = promisify(execFile);
148261
149262export class Reaper extends Events<Reaper.EventMap> {
150263 #transport = blankTransportState();
264 readonly #transportSignals = blankStateSignals();
265 readonly #extraSignals = blankExtraSignals();
266 // Toggle signals keyed by action command ID; #toggleByAction dedupes lookups
267 // so calling toggle() twice for the same action returns the same signal.
268 readonly #toggleSignals = new Map<number, Signal<boolean>>();
269 readonly #toggleByAction = new Map<ReaperActionId, ReadonlySignal<boolean>>();
270 readonly #state: ReaperState;
271 #watchWriteScheduled = false;
151272 #pendingScrubDelta = 0;
152273 #scrubTimer: ReturnType<typeof setTimeout> | null = null;
153274 #warnedOscSocket = false;
......@@ -160,10 +281,12 @@ export class Reaper extends Events<Reaper.EventMap> {
160281 #sendSocket: Socket;
161282 #closed = false;
162283 #feedbackWatcher: FSWatcher | null = null;
284 #feedbackPollTimer: ReturnType<typeof setInterval> | null = null;
163285 #feedbackLaunched = false;
164286
165287 constructor(options: ReaperOptions = {}) {
166288 super();
289 this.#state = this.#buildState();
167290 this.#oscHost = options.oscHost ?? DEFAULT_REAPER_OSC_HOST;
168291 this.#oscPort = options.oscPort ?? DEFAULT_REAPER_OSC_PORT;
169292 this.#oscBindPort = options.oscBindPort ?? DEFAULT_REAPER_OSC_BIND_PORT;
......@@ -181,6 +304,47 @@ export class Reaper extends Events<Reaper.EventMap> {
181304 return { ...this.#transport };
182305 }
183306
307 /**
308 * Live project state as per-field signals (tempo, time signature, transport
309 * flags, playhead, track selection, toggle states, ...). Read them inside a
310 * keypad face thunk (or any reactive scope) to have it re-render whenever that
311 * field changes:
312 *
313 * keypad.key("up-right", () => stack(Math.round(reaper.state.tempo()), "BPM"), ...);
314 * keypad.key("up-left", () => mdi("metronome").fg(reaper.state.metronomeOn() ? "amber" : "grey"), ...);
315 */
316 get state(): ReaperState {
317 return this.#state;
318 }
319
320 #buildState(): ReaperState {
321 return {
322 ...this.#transportSignals,
323 ...this.#extraSignals,
324 metronomeOn: this.#registerToggle("options-toggle-metronome"),
325 snapOn: this.#registerToggle("options-toggle-snapping"),
326 preRollOnPlay: this.#registerToggle("pre-roll-toggle-pre-roll-on-play"),
327 preRollOnRecord: this.#registerToggle("pre-roll-toggle-pre-roll-on-record"),
328 toggle: (actionId) => this.#registerToggle(actionId),
329 };
330 }
331
332 // Return the signal tracking `actionId`'s toggle state, creating (and starting
333 // to watch) it on first request. The feedback script reports every watched ID.
334 #registerToggle(actionId: ReaperActionId): ReadonlySignal<boolean> {
335 const existing = this.#toggleByAction.get(actionId);
336 if (existing) return existing;
337
338 const commandId = REAPER_ACTIONS[actionId];
339 const created = this.#toggleSignals.get(commandId) ?? signal(false);
340 if (commandId !== undefined) {
341 this.#toggleSignals.set(commandId, created);
342 this.#scheduleWatchListWrite();
343 }
344 this.#toggleByAction.set(actionId, created);
345 return created;
346 }
347
184348 #warnIfOscSurfaceMissing() {
185349 if (hasManagedOscSurface(readReaperConfig())) {
186350 return;
......@@ -205,6 +369,11 @@ export class Reaper extends Events<Reaper.EventMap> {
205369 this.#feedbackWatcher?.close();
206370 this.#feedbackWatcher = null;
207371
372 if (this.#feedbackPollTimer) {
373 clearInterval(this.#feedbackPollTimer);
374 this.#feedbackPollTimer = null;
375 }
376
208377 this.#receiveSocket.removeAllListeners();
209378 this.#sendSocket.removeAllListeners();
210379 closeSocket(this.#receiveSocket);
......@@ -296,14 +465,18 @@ export class Reaper extends Events<Reaper.EventMap> {
296465 );
297466 }
298467
299 // Live tempo + time signature come from a deferred REAPER Lua script that
300 // writes them to a JSON file whenever they change; we watch that file. This
301 // covers what OSC can't (REAPER has no time-signature feedback token).
468 // Live project state OSC can't report (tempo, time signature, track selection,
469 // action toggle states, ...) comes from a deferred REAPER Lua script that
470 // writes a JSON snapshot whenever it changes; we watch that file and fan it out
471 // into the state signals. The set of toggles it reports is driven by the watch
472 // list we write here (see #writeWatchList).
302473 async #startFeedback() {
303474 try {
304475 await this.#ensureManagedScripts();
476 await this.#writeWatchList();
305477 await this.#readFeedbackState();
306478 this.#watchFeedbackState();
479 this.#startFeedbackPolling();
307480 await this.#launchFeedbackScript();
308481 } catch (error) {
309482 this.#logFeedbackError(error);
......@@ -314,7 +487,10 @@ export class Reaper extends Events<Reaper.EventMap> {
314487 if (this.#feedbackWatcher || this.#closed) return;
315488 try {
316489 const watcher = watch(REAPER_FEEDBACK_DIR, (_event, filename) => {
317 if (!filename || filename === REAPER_FEEDBACK_FILE) {
490 // Re-read on any event naming the state file OR its temp sibling: the
491 // atomic-rename write touches both, and FSEvents may deliver only the
492 // `.tmp` name. A null filename (event with no name) re-reads too.
493 if (!filename || filename.startsWith(REAPER_FEEDBACK_FILE)) {
318494 void this.#readFeedbackState();
319495 }
320496 });
......@@ -322,10 +498,22 @@ export class Reaper extends Events<Reaper.EventMap> {
322498 watcher.unref?.();
323499 this.#feedbackWatcher = watcher;
324500 } catch {
325 // Directory may not be watchable; the periodic writes still land via reads.
501 // Directory may not be watchable; the polling backstop still reads it.
326502 }
327503 }
328504
505 // Backstop for missed/coalesced fs.watch events (see the interval constant):
506 // re-read the state file on a slow interval so tempo/time-signature changes
507 // always converge even when the watcher doesn't fire for them.
508 #startFeedbackPolling() {
509 if (this.#feedbackPollTimer || this.#closed) return;
510 const timer = setInterval(() => {
511 void this.#readFeedbackState();
512 }, REAPER_FEEDBACK_POLL_INTERVAL_MS);
513 timer.unref?.();
514 this.#feedbackPollTimer = timer;
515 }
516
329517 async #readFeedbackState() {
330518 let raw: string;
331519 try {
......@@ -333,10 +521,13 @@ export class Reaper extends Events<Reaper.EventMap> {
333521 } catch {
334522 return; // not written yet
335523 }
336 const patch = parseFeedbackState(raw);
337 if (patch) {
338 this.#updateTransport(patch, "osc");
524 const snapshot = parseFeedbackSnapshot(raw);
525 if (!snapshot) {
526 return;
339527 }
528 this.#updateTransport(snapshot.transport, "osc");
529 this.#updateExtras(snapshot.extras);
530 this.#updateToggles(snapshot.toggles);
340531 }
341532
342533 async #launchFeedbackScript() {
......@@ -406,12 +597,55 @@ export class Reaper extends Events<Reaper.EventMap> {
406597 source,
407598 };
408599 this.#transport = next;
600 this.#publishState(next);
409601
410602 if (!sameTransportState(previous, next)) {
411603 this.emit("transport", { ...next });
412604 }
413605 }
414606
607 // Push the new snapshot into the transport signals. Each `set` is a no-op when
608 // the value is unchanged, so a reactive scope only re-runs for the fields it
609 // actually reads that actually moved.
610 #publishState(next: ReaperTransportState) {
611 const signals = this.#transportSignals as Record<string, Signal<unknown>>;
612 for (const key of Object.keys(signals)) {
613 signals[key].set((next as Record<string, unknown>)[key]);
614 }
615 }
616
617 #updateExtras(extras: Partial<ReaperExtraState>) {
618 for (const key of Object.keys(extras) as (keyof ReaperExtraState)[]) {
619 (this.#extraSignals[key] as Signal<unknown>).set(extras[key]);
620 }
621 }
622
623 #updateToggles(toggles: Map<number, boolean>) {
624 for (const [commandId, on] of toggles) {
625 this.#toggleSignals.get(commandId)?.set(on);
626 }
627 }
628
629 // Coalesce a burst of toggle registrations into one write of the watch list.
630 #scheduleWatchListWrite() {
631 if (this.#watchWriteScheduled || this.#closed) return;
632 this.#watchWriteScheduled = true;
633 queueMicrotask(() => {
634 this.#watchWriteScheduled = false;
635 void this.#writeWatchList();
636 });
637 }
638
639 async #writeWatchList() {
640 try {
641 await this.#ensureManagedScripts();
642 const ids = [...this.#toggleSignals.keys()];
643 await writeFile(REAPER_WATCH_STATE_PATH, JSON.stringify(ids));
644 } catch {
645 // Best-effort: toggles just won't be reported until a later write lands.
646 }
647 }
648
415649 #queueScrubDelta(delta: number) {
416650 if (!Number.isFinite(delta) || delta === 0) {
417651 return;
......@@ -517,7 +751,7 @@ export class Reaper extends Events<Reaper.EventMap> {
517751
518752 #logFeedbackError(error: unknown) {
519753 this.#logError(
520 `Failed to start live tempo/time-signature feedback (${REAPER_FEEDBACK_STATE_PATH}).`,
754 `Failed to start live project-state feedback (${REAPER_FEEDBACK_STATE_PATH}).`,
521755 error,
522756 );
523757 }
......@@ -551,6 +785,23 @@ function blankTransportState(): ReaperTransportState {
551785 };
552786}
553787
788/** Seed the state signals from the same defaults as {@link blankTransportState}. */
789function blankStateSignals(): ReaperStateSignals {
790 const initial = blankTransportState();
791 return {
792 playing: signal(initial.playing),
793 paused: signal(initial.paused),
794 recording: signal(initial.recording),
795 repeatOn: signal(initial.repeatOn),
796 positionSeconds: signal(initial.positionSeconds),
797 positionString: signal(initial.positionString),
798 positionBeatsString: signal(initial.positionBeatsString),
799 tempo: signal(initial.tempo),
800 timeSignature: signal(initial.timeSignature),
801 source: signal(initial.source),
802 };
803}
804
554805function sameTransportState(
555806 left: ReaperTransportState,
556807 right: ReaperTransportState,
......@@ -566,22 +817,107 @@ function sameTransportState(
566817 && left.timeSignature === right.timeSignature;
567818}
568819
569/** Parse the feedback script's `{ "tempo": <bpm>, "timesig": "n/d" }` payload. */
570function parseFeedbackState(raw: string): ReaperTransportPatch | null {
571 let data: { tempo?: unknown; timesig?: unknown };
820/** Seed the extra-state signals with the same defaults as {@link ReaperExtraState}. */
821function blankExtraSignals(): ReaperExtraSignals {
822 return {
823 currentMeasure: signal(1),
824 editCursorSeconds: signal(0),
825 projectLengthSeconds: signal(0),
826 timeSelectionStart: signal(0),
827 timeSelectionEnd: signal(0),
828 timeSelectionActive: signal(false),
829 trackCount: signal(0),
830 selectedTrackCount: signal(0),
831 selectedTrackName: signal(""),
832 selectedTrackIndex: signal(0),
833 masterVolumeDb: signal(0),
834 projectName: signal(""),
835 projectDirty: signal(false),
836 };
837}
838
839/**
840 * Parse the feedback script's JSON snapshot into the three ways we apply it:
841 * a transport patch, the extra-state fields, and the action toggle map. Tolerant
842 * of missing/garbage keys — anything unrecognized is simply skipped.
843 */
844function parseFeedbackSnapshot(raw: string): FeedbackSnapshot | null {
845 let data: Record<string, unknown>;
572846 try {
573847 data = JSON.parse(raw);
574848 } catch {
575849 return null;
576850 }
851 if (typeof data !== "object" || data === null) {
852 return null;
853 }
854 return {
855 transport: parseFeedbackTransport(data),
856 extras: parseFeedbackExtras(data),
857 toggles: parseFeedbackToggles(data.toggles),
858 };
859}
860
861// Only low-frequency, human-driven fields come from the feedback file. The
862// playhead position deliberately does not — it belongs on OSC. So the transport
863// signals positionSeconds/positionString/positionBeatsString stay OSC/optimistic
864// only (unused until OSC position tokens are added), and this file never rewrites
865// on playback.
866function parseFeedbackTransport(data: Record<string, unknown>): ReaperTransportPatch {
577867 const patch: ReaperTransportPatch = {};
578 if (typeof data.tempo === "number" && Number.isFinite(data.tempo)) {
579 patch.tempo = data.tempo;
868 if (isFiniteNumber(data.tempo)) patch.tempo = data.tempo;
869 if (isNonEmptyString(data.timesig)) patch.timeSignature = data.timesig;
870 if (typeof data.repeat === "number") patch.repeatOn = data.repeat !== 0;
871 return patch;
872}
873
874function parseFeedbackExtras(
875 data: Record<string, unknown>,
876): Partial<ReaperExtraState> {
877 const extras: Record<string, unknown> = {};
878 for (const [key, source, kind] of EXTRA_FIELDS) {
879 const value = coerceExtra(data[source], kind);
880 if (value !== undefined) extras[key] = value;
580881 }
581 if (typeof data.timesig === "string" && data.timesig.length > 0) {
582 patch.timeSignature = data.timesig;
882 return extras as Partial<ReaperExtraState>;
883}
884
885function coerceExtra(
886 value: unknown,
887 kind: "number" | "string" | "boolean",
888): number | string | boolean | undefined {
889 switch (kind) {
890 case "number":
891 return isFiniteNumber(value) ? value : undefined;
892 case "string":
893 return typeof value === "string" ? value : undefined;
894 case "boolean":
895 if (typeof value === "boolean") return value;
896 if (typeof value === "number") return value !== 0;
897 return undefined;
583898 }
584 return Object.keys(patch).length > 0 ? patch : null;
899}
900
901function parseFeedbackToggles(value: unknown): Map<number, boolean> {
902 const toggles = new Map<number, boolean>();
903 if (typeof value !== "object" || value === null) {
904 return toggles;
905 }
906 for (const [key, raw] of Object.entries(value as Record<string, unknown>)) {
907 const commandId = Number(key);
908 if (Number.isInteger(commandId)) {
909 toggles.set(commandId, raw === 1 || raw === true);
910 }
911 }
912 return toggles;
913}
914
915function isFiniteNumber(value: unknown): value is number {
916 return typeof value === "number" && Number.isFinite(value);
917}
918
919function isNonEmptyString(value: unknown): value is string {
920 return typeof value === "string" && value.length > 0;
585921}
586922
587923function optimisticTransportPatchForAction(
control/src/icons.ts+16-5
......@@ -252,7 +252,13 @@ function glyph(inner: string, size: number, style: GlyphStyle): string {
252252}
253253
254254const SANS_FONT = "Helvetica, Arial, sans-serif";
255const SERIF_FONT = "Georgia, 'Times New Roman', Times, serif";
255// Lead with a lining-figures serif. Georgia (and most "text" serifs) use
256// old-style figures where digits sit at different heights — 0/1/2 are short,
257// 3/4/5/7/9 descend, 6/8 ascend — so no single vertical offset can center
258// every numerator/denominator pair. Times uses lining figures: all digits
259// share one baseline and cap-height, which is also how music engraving sets
260// time signatures.
261const SERIF_FONT = "'Times New Roman', Times, Georgia, serif";
256262
257263interface TextStyle {
258264 family?: string;
......@@ -305,10 +311,14 @@ function stackInner(lines: readonly StackSpec[], fg: string): string {
305311
306312// Four horizontal staff lines with the serif numerals stacked across them —
307313// numerator in the upper half, denominator in the lower, like real sheet music.
308const STAFF_LINES = 4;
309const STAFF_GAP = 12;
314const STAFF_LINES = 5;
315const STAFF_GAP = 16;
310316const STAFF_INSET = 16;
311317const TIMESIG_GLYPH = 40;
318// Vertical distance from the middle staff line to each numeral's center. The
319// numerator sits this far above the center, the denominator the same distance
320// below, so the pair is balanced regardless of which digits are shown.
321const TIMESIG_STACK_OFFSET = 16;
312322
313323function timeSignatureInner(
314324 numerator: string,
......@@ -325,9 +335,10 @@ function timeSignatureInner(
325335 }
326336 const style: TextStyle = { family: SERIF_FONT, weight: 700 };
327337 const numeral = TIMESIG_GLYPH;
338 const middle = KEY_SIZE / 2;
328339 const glyphs =
329 textAt(KEY_SIZE / 2, KEY_SIZE / 2 - 16, numerator, fg, numeral, style) +
330 textAt(KEY_SIZE / 2, KEY_SIZE / 2 + 16, denominator, fg, numeral, style);
340 textAt(KEY_SIZE / 2, middle - TIMESIG_STACK_OFFSET, numerator, fg, numeral, style) +
341 textAt(KEY_SIZE / 2, middle + TIMESIG_STACK_OFFSET, denominator, fg, numeral, style);
331342 return staff + glyphs;
332343}
333344