1import { Events } from "@clo/lib/Events.ts";
2import * as log from "@clo/lib/log.ts";
3import { execFile } from "node:child_process";
4import { createSocket, type Socket } from "node:dgram";
5import { type FSWatcher, readFileSync, watch } from "node:fs";
6import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
7import { basename, dirname, join } from "node:path";
8import process from "node:process";
9import { fileURLToPath } from "node:url";
10import { promisify } from "node:util";
11import { REAPER_ACTIONS, type ReaperActionId } from "./Reaper/actions.ts";
12import { type ReadonlySignal, signal, type Signal } from "./signals.ts";
13const console = log.scoped("reaper");
14
15export type { ReaperActionId } from "./Reaper/actions.ts";
16
17type ReaperCommandId = number;
18
19export interface ReaperOptions {
20 oscHost?: string;
21 oscPort?: number;
22 oscBindPort?: number;
23}
24
25export interface ReaperTransportState {
26 playing: boolean;
27 paused: boolean;
28 recording: boolean;
29 repeatOn: boolean;
30 positionSeconds: number;
31 positionString: string;
32 positionBeatsString: string;
33 /** Project tempo in BPM (live, from the feedback script). */
34 tempo: number;
35 /** Project time signature as "num/denom" (live, from the feedback script). */
36 timeSignature: string;
37 readAtMs: number;
38 source: "osc" | "optimistic";
39}
40
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
143type ReaperScriptName = string;
144
145type OscScalar = number | string | boolean;
146type OscMessage = {
147 address: string;
148 args: OscScalar[];
149};
150type ReaperTransportPatch = Partial<ReaperTransportState>;
151
152const DEFAULT_REAPER_BIN = "/Applications/REAPER.app/Contents/MacOS/REAPER";
153const DEFAULT_REAPER_OSC_HOST = process.env.REAPER_OSC_HOST ?? "127.0.0.1";
154const DEFAULT_REAPER_OSC_PORT = readNumberEnv(
155 ["REAPER_OSC_PORT", "REAPER_OSC_TARGET_PORT"],
156 58_000,
157);
158const DEFAULT_REAPER_OSC_BIND_PORT = readNumberEnv(
159 ["REAPER_OSC_BIND_PORT", "REAPER_OSC_FEEDBACK_PORT"],
160 58_001,
161);
162const SCRUB_FLUSH_MS = 25;
163const SCRUB_OSC_VALUE_PER_TICK = readNumberEnv(
164 [
165 "REAPER_SCRUB_OSC_VALUE_PER_TICK",
166 "REAPER_SCRUB_SECONDS_PER_TICK",
167 "REAPER_JOG_SECONDS_PER_TICK",
168 ],
169 0.1,
170);
171const MAX_SCRUB_OSC_VALUE = readNumberEnv(
172 [
173 "REAPER_MAX_SCRUB_OSC_VALUE",
174 "REAPER_MAX_SCRUB_STEP_SECONDS",
175 "REAPER_MAX_JOG_STEP_SECONDS",
176 ],
177 5,
178);
179const REAPER_SUPPORT_SOURCE_DIR = join(
180 dirname(fileURLToPath(import.meta.url)),
181 "..",
182 "config",
183 "reaper",
184);
185const DEFAULT_REAPER_RESOURCE_DIR = join(
186 process.env.HOME ?? "",
187 "Library",
188 "Application Support",
189 "REAPER",
190);
191const DEFAULT_REAPER_CONFIG_PATH = join(
192 DEFAULT_REAPER_RESOURCE_DIR,
193 "reaper.ini",
194);
195const DEFAULT_REAPER_SCRIPT_TARGET_DIR = join(
196 DEFAULT_REAPER_RESOURCE_DIR,
197 "Scripts",
198 "clover",
199);
200const DEFAULT_REAPER_OSC_TARGET_DIR = join(
201 DEFAULT_REAPER_RESOURCE_DIR,
202 "OSC",
203);
204const REAPER_SCRIPT_TARGET_DIR = process.env.REAPER_SCRIPTS_DIR
205 ?? DEFAULT_REAPER_SCRIPT_TARGET_DIR;
206// The feedback script writes live tempo/time-signature here (next to itself);
207// we watch the file for changes. See config/reaper/scripts/clover_feedback.lua.
208const REAPER_FEEDBACK_DIR = join(REAPER_SCRIPT_TARGET_DIR, "scripts");
209const REAPER_FEEDBACK_FILE = "state.json";
210const 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");
214const 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;
222const REAPER_OSC_TARGET_DIR = process.env.REAPER_OSC_DIR
223 ?? DEFAULT_REAPER_OSC_TARGET_DIR;
224const OSC_PATTERN_FILE = "CloverAutomation.ReaperOSC";
225const OSC_PATTERN_NAME = stripReaperOscExtension(OSC_PATTERN_FILE);
226const OSC_PATTERN_CONFIG = `# OSC pattern config file for Clover Creative Control's REAPER integration.
227DEVICE_TRACK_COUNT 1
228DEVICE_SEND_COUNT 0
229DEVICE_RECEIVE_COUNT 0
230DEVICE_FX_COUNT 0
231DEVICE_FX_PARAM_COUNT 0
232DEVICE_FX_INST_PARAM_COUNT 0
233DEVICE_MARKER_COUNT 0
234DEVICE_REGION_COUNT 0
235
236REAPER_TRACK_FOLLOWS REAPER
237DEVICE_TRACK_FOLLOWS DEVICE
238DEVICE_TRACK_BANK_FOLLOWS DEVICE
239DEVICE_FX_FOLLOWS DEVICE
240DEVICE_ROTARY_CENTER 0
241
242# ----------------------------------------------------------------
243
244RECORD t/clover/record
245STOP t/clover/stop
246PLAY t/clover/play
247PAUSE t/clover/pause
248SCRUB r/clover/scrub
249
250ACTION i/clover/action s/clover/action/str t/clover/action/@`;
251const OSC_ADDRESS = {
252 action: "/clover/action",
253 actionString: "/clover/action/str",
254 play: "/clover/play",
255 stop: "/clover/stop",
256 pause: "/clover/pause",
257 record: "/clover/record",
258 scrub: "/clover/scrub",
259} as const;
260const execFileAsync = promisify(execFile);
261
262export class Reaper extends Events<Reaper.EventMap> {
263 #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;
272 #pendingScrubDelta = 0;
273 #scrubTimer: ReturnType<typeof setTimeout> | null = null;
274 #warnedOscSocket = false;
275 #managedScriptsPromise: Promise<void> | null = null;
276 #oscHost: string;
277 #oscPort: number;
278 #oscBindPort: number;
279 #receivedOscFeedback = false;
280 #receiveSocket: Socket;
281 #sendSocket: Socket;
282 #closed = false;
283 #feedbackWatcher: FSWatcher | null = null;
284 #feedbackPollTimer: ReturnType<typeof setInterval> | null = null;
285 #feedbackLaunched = false;
286
287 constructor(options: ReaperOptions = {}) {
288 super();
289 this.#state = this.#buildState();
290 this.#oscHost = options.oscHost ?? DEFAULT_REAPER_OSC_HOST;
291 this.#oscPort = options.oscPort ?? DEFAULT_REAPER_OSC_PORT;
292 this.#oscBindPort = options.oscBindPort ?? DEFAULT_REAPER_OSC_BIND_PORT;
293 this.#receiveSocket = createSocket("udp4");
294 this.#sendSocket = createSocket("udp4");
295 this.#setupOscSockets();
296 this.#warnIfOscSurfaceMissing();
297 void this.#installOscPatternConfig().catch((error) => {
298 this.#logOscPatternInstallError(error);
299 });
300 void this.#startFeedback();
301 }
302
303 get transport(): ReaperTransportState {
304 return { ...this.#transport };
305 }
306
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
348 #warnIfOscSurfaceMissing() {
349 if (hasManagedOscSurface(readReaperConfig())) {
350 return;
351 }
352
353 console.info(
354 `${OSC_PATTERN_FILE} is not registered yet. `
355 + `Add an OSC control surface manually using the "${OSC_PATTERN_NAME}" pattern, set REAPER's local listen port to `
356 + `${this.#oscPort}, and send to ${this.#oscHost}:${this.#oscBindPort}. `
357 + "The REAPER web interface is not required for this config.",
358 );
359 }
360
361 close() {
362 if (this.#closed) {
363 return;
364 }
365
366 this.#closed = true;
367 this.#clearScrubTimer();
368
369 this.#feedbackWatcher?.close();
370 this.#feedbackWatcher = null;
371
372 if (this.#feedbackPollTimer) {
373 clearInterval(this.#feedbackPollTimer);
374 this.#feedbackPollTimer = null;
375 }
376
377 this.#receiveSocket.removeAllListeners();
378 this.#sendSocket.removeAllListeners();
379 closeSocket(this.#receiveSocket);
380 closeSocket(this.#sendSocket);
381 }
382
383 async runAction(actionId: ReaperActionId) {
384 const sent = await this.#sendCommand(REAPER_ACTIONS[actionId]);
385 if (!sent) {
386 return sent;
387 }
388
389 const patch = optimisticTransportPatchForAction(actionId, this.#transport);
390 if (patch) {
391 this.#updateTransport(patch, "optimistic");
392 }
393
394 return sent;
395 }
396
397 async #sendCommand(commandId: ReaperCommandId) {
398 return this.#sendLoggedOscMessage(
399 OSC_ADDRESS.action,
400 [commandId],
401 `run action ${commandId}`,
402 );
403 }
404
405 async #ensureManagedScripts() {
406 if (!this.#managedScriptsPromise) {
407 this.#managedScriptsPromise = this.#installManagedScripts()
408 .catch((error) => {
409 this.#managedScriptsPromise = null;
410 this.#logInstallError(error);
411 throw error;
412 });
413 }
414
415 await this.#managedScriptsPromise;
416 }
417
418 async runScript(name: ReaperScriptName) {
419 try {
420 await this.#ensureManagedScripts();
421 await this.#runManagedScript(name);
422 return true;
423 } catch (error) {
424 this.#logScriptError(name, error);
425 return false;
426 }
427 }
428
429 scrub(value: number) {
430 this.#queueScrubDelta(value * SCRUB_OSC_VALUE_PER_TICK);
431 }
432
433 async #flushScrub() {
434 const delta = clamp(
435 this.#pendingScrubDelta,
436 -MAX_SCRUB_OSC_VALUE,
437 MAX_SCRUB_OSC_VALUE,
438 );
439 this.#pendingScrubDelta = 0;
440
441 if (delta === 0) {
442 return;
443 }
444
445 await this.#sendLoggedOscMessage(
446 OSC_ADDRESS.scrub,
447 [delta],
448 "scrub playhead",
449 );
450 }
451
452 async #installManagedScripts() {
453 await mkdir(REAPER_SCRIPT_TARGET_DIR, { recursive: true });
454 await cp(REAPER_SUPPORT_SOURCE_DIR, REAPER_SCRIPT_TARGET_DIR, {
455 force: true,
456 recursive: true,
457 });
458 }
459
460 async #installOscPatternConfig() {
461 await mkdir(REAPER_OSC_TARGET_DIR, { recursive: true });
462 await writeFile(
463 join(REAPER_OSC_TARGET_DIR, OSC_PATTERN_FILE),
464 OSC_PATTERN_CONFIG,
465 );
466 }
467
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).
473 async #startFeedback() {
474 try {
475 await this.#ensureManagedScripts();
476 await this.#writeWatchList();
477 await this.#readFeedbackState();
478 this.#watchFeedbackState();
479 this.#startFeedbackPolling();
480 await this.#launchFeedbackScript();
481 } catch (error) {
482 this.#logFeedbackError(error);
483 }
484 }
485
486 #watchFeedbackState() {
487 if (this.#feedbackWatcher || this.#closed) return;
488 try {
489 const watcher = watch(REAPER_FEEDBACK_DIR, (_event, filename) => {
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)) {
494 void this.#readFeedbackState();
495 }
496 });
497 watcher.on("error", () => {});
498 watcher.unref?.();
499 this.#feedbackWatcher = watcher;
500 } catch {
501 // Directory may not be watchable; the polling backstop still reads it.
502 }
503 }
504
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
517 async #readFeedbackState() {
518 let raw: string;
519 try {
520 raw = await readFile(REAPER_FEEDBACK_STATE_PATH, "utf8");
521 } catch {
522 return; // not written yet
523 }
524 const snapshot = parseFeedbackSnapshot(raw);
525 if (!snapshot) {
526 return;
527 }
528 this.#updateTransport(snapshot.transport, "osc");
529 this.#updateExtras(snapshot.extras);
530 this.#updateToggles(snapshot.toggles);
531 }
532
533 async #launchFeedbackScript() {
534 if (this.#feedbackLaunched || this.#closed) return;
535 // Only start it once REAPER is actually up, so we don't log a spurious error.
536 if (!await isProcessRunning(reaperProcessName())) return;
537 this.#feedbackLaunched = true;
538 await this.runScript(REAPER_FEEDBACK_SCRIPT);
539 }
540
541 async #runManagedScript(name: ReaperScriptName) {
542 if (!await isProcessRunning(reaperProcessName())) {
543 throw new Error(
544 "REAPER is not running, so Clover skipped launching the script instead of opening it automatically.",
545 );
546 }
547
548 await execFileAsync(process.env.REAPER_BIN ?? DEFAULT_REAPER_BIN, [
549 "-nonewinst",
550 join(REAPER_SCRIPT_TARGET_DIR, "scripts", `${name}.lua`),
551 ]);
552 }
553
554 #setupOscSockets() {
555 this.#receiveSocket.on("message", (packet) => {
556 this.#handleOscPacket(packet);
557 });
558
559 this.#receiveSocket.on("error", (error) => {
560 this.#logOscSocketError(error);
561 });
562
563 this.#sendSocket.on("error", (error) => {
564 this.#logOscSocketError(error);
565 });
566
567 this.#receiveSocket.bind(this.#oscBindPort);
568 }
569
570 #handleOscPacket(packet: Buffer) {
571 const patch = transportPatchForOscPacket(packet);
572 if (!patch) {
573 return;
574 }
575
576 if (!this.#receivedOscFeedback) {
577 this.#receivedOscFeedback = true;
578 this.emit("osc-feedback");
579 // REAPER is confirmed up — (re)start the feedback script if we hadn't yet.
580 void this.#launchFeedbackScript().catch((error) => {
581 this.#logFeedbackError(error);
582 });
583 }
584
585 this.#updateTransport(patch, "osc");
586 }
587
588 #updateTransport(
589 patch: ReaperTransportPatch,
590 source: ReaperTransportState["source"],
591 ) {
592 const previous = this.#transport;
593 const next = {
594 ...previous,
595 ...patch,
596 readAtMs: Date.now(),
597 source,
598 };
599 this.#transport = next;
600 this.#publishState(next);
601
602 if (!sameTransportState(previous, next)) {
603 this.emit("transport", { ...next });
604 }
605 }
606
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
649 #queueScrubDelta(delta: number) {
650 if (!Number.isFinite(delta) || delta === 0) {
651 return;
652 }
653
654 this.#pendingScrubDelta += clamp(
655 delta,
656 -MAX_SCRUB_OSC_VALUE,
657 MAX_SCRUB_OSC_VALUE,
658 );
659
660 if (this.#scrubTimer) {
661 return;
662 }
663
664 this.#scrubTimer = setTimeout(() => {
665 this.#scrubTimer = null;
666 void this.#flushScrub();
667 }, SCRUB_FLUSH_MS);
668 }
669
670 #clearScrubTimer() {
671 if (!this.#scrubTimer) {
672 return;
673 }
674
675 clearTimeout(this.#scrubTimer);
676 this.#scrubTimer = null;
677 }
678
679 async #sendLoggedOscMessage(
680 address: string,
681 args: OscScalar[],
682 action: string,
683 ) {
684 try {
685 await this.#sendOscMessage(address, args);
686 return true;
687 } catch (error) {
688 this.#logOscSendError(action, error);
689 return false;
690 }
691 }
692
693 async #sendOscMessage(address: string, args: OscScalar[] = []) {
694 const payload = encodeOscMessage(address, args);
695 await new Promise<void>((resolve, reject) => {
696 this.#sendSocket.send(
697 payload,
698 this.#oscPort,
699 this.#oscHost,
700 (error) => {
701 if (error) {
702 reject(error);
703 return;
704 }
705 resolve();
706 },
707 );
708 });
709 }
710
711 #logOscSendError(action: string, error: unknown) {
712 this.#logError(
713 `Failed to ${action} via OSC ${this.#oscHost}:${this.#oscPort}.`,
714 error,
715 );
716 }
717
718 #logScriptError(name: ReaperScriptName, error: unknown) {
719 this.#logError(
720 `Failed to run script "${name}" from ${REAPER_SCRIPT_TARGET_DIR}.`,
721 error,
722 );
723 }
724
725 #logOscSocketError(error: unknown) {
726 if (!this.#warnedOscSocket) {
727 this.#warnedOscSocket = true;
728 this.#logError(
729 `Failed to bind OSC feedback socket on ${this.#oscBindPort}.`,
730 error,
731 );
732 return;
733 }
734
735 console.error(`[REAPER] ${formatError(error)}`);
736 }
737
738 #logInstallError(error: unknown) {
739 this.#logError(
740 `Failed to install/update managed scripts in ${REAPER_SCRIPT_TARGET_DIR}.`,
741 error,
742 );
743 }
744
745 #logOscPatternInstallError(error: unknown) {
746 this.#logError(
747 `Failed to install/update ${OSC_PATTERN_FILE} in ${REAPER_OSC_TARGET_DIR}.`,
748 error,
749 );
750 }
751
752 #logFeedbackError(error: unknown) {
753 this.#logError(
754 `Failed to start live project-state feedback (${REAPER_FEEDBACK_STATE_PATH}).`,
755 error,
756 );
757 }
758
759 #logError(message: string, error: unknown) {
760 console.error(`[REAPER] ${message}`);
761 console.error(`[REAPER] ${formatError(error)}`);
762 }
763}
764
765export declare namespace Reaper {
766 export type EventMap = {
767 "transport": [transport: ReaperTransportState];
768 "osc-feedback": [];
769 };
770}
771
772function blankTransportState(): ReaperTransportState {
773 return {
774 playing: false,
775 paused: false,
776 recording: false,
777 repeatOn: false,
778 positionSeconds: 0,
779 positionString: "",
780 positionBeatsString: "",
781 tempo: 120,
782 timeSignature: "4/4",
783 readAtMs: 0,
784 source: "optimistic",
785 };
786}
787
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
805function sameTransportState(
806 left: ReaperTransportState,
807 right: ReaperTransportState,
808) {
809 return left.playing === right.playing
810 && left.paused === right.paused
811 && left.recording === right.recording
812 && left.repeatOn === right.repeatOn
813 && left.positionSeconds === right.positionSeconds
814 && left.positionString === right.positionString
815 && left.positionBeatsString === right.positionBeatsString
816 && left.tempo === right.tempo
817 && left.timeSignature === right.timeSignature;
818}
819
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>;
846 try {
847 data = JSON.parse(raw);
848 } catch {
849 return null;
850 }
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 {
867 const patch: ReaperTransportPatch = {};
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;
881 }
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;
898 }
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;
921}
922
923function optimisticTransportPatchForAction(
924 actionId: ReaperActionId,
925 transport: ReaperTransportState,
926): ReaperTransportPatch | null {
927 switch (actionId) {
928 case "transport-play":
929 case "transport-play-skip-time-selection":
930 return { playing: true, paused: false };
931
932 case "transport-stop":
933 case "transport-stop-delete-all-recorded-media":
934 case "transport-stop-save-all-recorded-media":
935 return { playing: false, paused: false, recording: false };
936
937 case "transport-play-stop":
938 case "transport-play-stop-move-edit-cursor-on-stop":
939 return transport.playing || transport.paused || transport.recording
940 ? { playing: false, paused: false, recording: false }
941 : { playing: true, paused: false };
942
943 case "transport-record":
944 return transport.recording
945 ? { playing: false, paused: false, recording: false }
946 : { recording: true, playing: true, paused: false };
947
948 case "transport-pause":
949 if (transport.paused) {
950 return { paused: false, playing: true };
951 }
952 if (transport.playing || transport.recording) {
953 return { paused: true, playing: false };
954 }
955 return null;
956
957 case "transport-play-pause":
958 if (transport.paused) {
959 return { paused: false, playing: true };
960 }
961 if (transport.playing || transport.recording) {
962 return { paused: true, playing: false };
963 }
964 return { playing: true, paused: false };
965
966 case "transport-toggle-repeat":
967 return { repeatOn: !transport.repeatOn };
968
969 default:
970 return null;
971 }
972}
973
974function transportPatchForOscMessage(
975 message: OscMessage,
976): ReaperTransportPatch | null {
977 switch (message.address) {
978 case OSC_ADDRESS.record: {
979 const recording = readOscBoolean(message.args[0]);
980 if (recording === null) {
981 return null;
982 }
983
984 return recording
985 ? { recording, playing: true, paused: false }
986 : { recording };
987 }
988
989 case OSC_ADDRESS.play: {
990 const playing = readOscBoolean(message.args[0]);
991 if (playing === null) {
992 return null;
993 }
994
995 return playing ? { playing, paused: false } : { playing };
996 }
997
998 case OSC_ADDRESS.pause: {
999 const paused = readOscBoolean(message.args[0]);
1000 if (paused === null) {
1001 return null;
1002 }
1003
1004 return paused ? { paused, playing: false } : { paused };
1005 }
1006
1007 case OSC_ADDRESS.stop:
1008 return readOscBoolean(message.args[0])
1009 ? { playing: false, paused: false, recording: false }
1010 : null;
1011
1012 default:
1013 return null;
1014 }
1015}
1016
1017function transportPatchForOscPacket(
1018 packet: Buffer,
1019): ReaperTransportPatch | null {
1020 const messages = parseOscPacket(packet);
1021 if (!messages) {
1022 return null;
1023 }
1024
1025 let patch: ReaperTransportPatch | null = null;
1026 for (const message of messages) {
1027 const next = transportPatchForOscMessage(message);
1028 if (!next) {
1029 continue;
1030 }
1031
1032 patch = patch ? { ...patch, ...next } : next;
1033 }
1034
1035 return patch;
1036}
1037
1038function hasManagedOscSurface(config: string) {
1039 for (const line of config.split(/\r?\n/u)) {
1040 const match = /^csurf_\d+=(.+)$/u.exec(line.trim());
1041 if (!match) {
1042 continue;
1043 }
1044
1045 const tokens = match[1]?.match(/"[^"]*"|'[^']*'|[^ ]+/gu) ?? [];
1046 if (tokens[0] !== "OSC") {
1047 continue;
1048 }
1049
1050 const normalizedTokens = tokens.map((token) => unquoteReaperToken(token));
1051 if (
1052 normalizedTokens.includes(OSC_PATTERN_FILE)
1053 || normalizedTokens.includes(OSC_PATTERN_NAME)
1054 ) {
1055 return true;
1056 }
1057 }
1058
1059 return false;
1060}
1061
1062function readReaperConfig() {
1063 try {
1064 return readFileSync(DEFAULT_REAPER_CONFIG_PATH, "utf8");
1065 } catch {
1066 return "";
1067 }
1068}
1069
1070function closeSocket(socket: Socket) {
1071 try {
1072 socket.close();
1073 } catch {}
1074}
1075
1076function formatError(error: unknown) {
1077 return error instanceof Error ? error.message : String(error);
1078}
1079
1080function unquoteReaperToken(token: string) {
1081 if (
1082 (token.startsWith("'") && token.endsWith("'"))
1083 || (token.startsWith("\"") && token.endsWith("\""))
1084 ) {
1085 return token.slice(1, -1);
1086 }
1087 return token;
1088}
1089
1090function stripReaperOscExtension(fileName: string) {
1091 return fileName.endsWith(".ReaperOSC")
1092 ? fileName.slice(0, -".ReaperOSC".length)
1093 : fileName;
1094}
1095
1096function readNumberEnv(names: ReadonlyArray<string>, fallback: number) {
1097 for (const name of names) {
1098 const numeric = Number(process.env[name]);
1099 if (Number.isFinite(numeric)) {
1100 return numeric;
1101 }
1102 }
1103
1104 return fallback;
1105}
1106
1107function clamp(value: number, min: number, max: number) {
1108 return Math.min(max, Math.max(min, value));
1109}
1110
1111function reaperProcessName() {
1112 return basename(process.env.REAPER_BIN ?? DEFAULT_REAPER_BIN);
1113}
1114
1115async function isProcessRunning(processName: string) {
1116 try {
1117 await execFileAsync("pgrep", ["-x", processName]);
1118 return true;
1119 } catch {
1120 return false;
1121 }
1122}
1123
1124function parseOscPacket(packet: Buffer): OscMessage[] | null {
1125 if (isOscBundle(packet)) {
1126 return parseOscBundle(packet);
1127 }
1128
1129 const message = parseOscMessage(packet);
1130 return message ? [message] : null;
1131}
1132
1133function isOscBundle(packet: Buffer) {
1134 return packet.subarray(0, 8).equals(Buffer.from("#bundle\0"));
1135}
1136
1137function parseOscBundle(packet: Buffer): OscMessage[] | null {
1138 const bundleHeader = readOscString(packet, 0);
1139 if (!bundleHeader || bundleHeader.value !== "#bundle") {
1140 return null;
1141 }
1142
1143 let offset = nextOscOffset(bundleHeader.nextOffset);
1144 if (offset + 8 > packet.length) {
1145 return null;
1146 }
1147
1148 offset += 8;
1149
1150 const messages: OscMessage[] = [];
1151 while (offset < packet.length) {
1152 if (offset + 4 > packet.length) {
1153 return null;
1154 }
1155
1156 const elementSize = packet.readInt32BE(offset);
1157 offset += 4;
1158
1159 if (elementSize < 0 || offset + elementSize > packet.length) {
1160 return null;
1161 }
1162
1163 const element = packet.subarray(offset, offset + elementSize);
1164 offset += elementSize;
1165
1166 const elementMessages = parseOscPacket(element);
1167 if (!elementMessages) {
1168 return null;
1169 }
1170
1171 messages.push(...elementMessages);
1172 }
1173
1174 return messages;
1175}
1176
1177function parseOscMessage(packet: Buffer): OscMessage | null {
1178 const address = readOscString(packet, 0);
1179 if (!address) {
1180 return null;
1181 }
1182
1183 const typeTags = readOscString(packet, nextOscOffset(address.nextOffset));
1184 if (!typeTags || !typeTags.value.startsWith(",")) {
1185 return null;
1186 }
1187
1188 const args: OscScalar[] = [];
1189 let offset = nextOscOffset(typeTags.nextOffset);
1190
1191 for (const tag of typeTags.value.slice(1)) {
1192 switch (tag) {
1193 case "i":
1194 if (offset + 4 > packet.length) return null;
1195 args.push(packet.readInt32BE(offset));
1196 offset += 4;
1197 break;
1198
1199 case "f":
1200 if (offset + 4 > packet.length) return null;
1201 args.push(packet.readFloatBE(offset));
1202 offset += 4;
1203 break;
1204
1205 case "s": {
1206 const value = readOscString(packet, offset);
1207 if (!value) return null;
1208 args.push(value.value);
1209 offset = nextOscOffset(value.nextOffset);
1210 break;
1211 }
1212
1213 case "T":
1214 args.push(true);
1215 break;
1216
1217 case "F":
1218 args.push(false);
1219 break;
1220
1221 default:
1222 return null;
1223 }
1224 }
1225
1226 return { address: address.value, args };
1227}
1228
1229function encodeOscMessage(address: string, args: OscScalar[] = []) {
1230 const parts = [encodeOscString(address)];
1231 const typeTags = "," + args.map((arg) => oscTypeTag(arg)).join("");
1232 parts.push(encodeOscString(typeTags));
1233
1234 for (const arg of args) {
1235 parts.push(encodeOscArgument(arg));
1236 }
1237
1238 return Buffer.concat(parts);
1239}
1240
1241function oscTypeTag(value: OscScalar) {
1242 if (typeof value === "number") {
1243 return Number.isInteger(value) ? "i" : "f";
1244 }
1245 if (typeof value === "boolean") {
1246 return value ? "T" : "F";
1247 }
1248 return "s";
1249}
1250
1251function encodeOscArgument(value: OscScalar) {
1252 if (typeof value === "number") {
1253 const buffer = Buffer.alloc(4);
1254 if (Number.isInteger(value)) {
1255 buffer.writeInt32BE(value, 0);
1256 } else {
1257 buffer.writeFloatBE(value, 0);
1258 }
1259 return buffer;
1260 }
1261
1262 if (typeof value === "boolean") {
1263 return Buffer.alloc(0);
1264 }
1265
1266 return encodeOscString(value);
1267}
1268
1269function encodeOscString(value: string) {
1270 const buffer = Buffer.from(value + "\0", "utf8");
1271 const padding = (4 - (buffer.length % 4)) % 4;
1272 return padding === 0
1273 ? buffer
1274 : Buffer.concat([buffer, Buffer.alloc(padding)]);
1275}
1276
1277function readOscString(buffer: Buffer, offset: number) {
1278 let end = offset;
1279 while (end < buffer.length && buffer[end] !== 0) {
1280 end += 1;
1281 }
1282
1283 if (end >= buffer.length) {
1284 return null;
1285 }
1286
1287 return {
1288 value: buffer.toString("utf8", offset, end),
1289 nextOffset: end + 1,
1290 };
1291}
1292
1293function nextOscOffset(offset: number) {
1294 return offset + ((4 - (offset % 4)) % 4);
1295}
1296
1297function readOscBoolean(value: OscScalar | undefined) {
1298 if (typeof value === "boolean") {
1299 return value;
1300 }
1301 if (typeof value === "number") {
1302 return value !== 0;
1303 }
1304 if (typeof value === "string") {
1305 if (value === "0") return false;
1306 if (value === "1") return true;
1307 }
1308 return null;
1309}