| ... | @@ -9,6 +9,7 @@ import process from "node:process"; | ... | @@ -9,6 +9,7 @@ import process from "node:process"; |
| 9 | import { fileURLToPath } from "node:url"; | 9 | import { fileURLToPath } from "node:url"; |
| 10 | import { promisify } from "node:util"; | 10 | import { promisify } from "node:util"; |
| 11 | import { REAPER_ACTIONS, type ReaperActionId } from "./Reaper/actions.ts"; | 11 | import { REAPER_ACTIONS, type ReaperActionId } from "./Reaper/actions.ts"; |
| | 12 | import { type ReadonlySignal, signal, type Signal } from "./signals.ts"; |
| 12 | const console = log.scoped("reaper"); | 13 | const console = log.scoped("reaper"); |
| 13 | | 14 | |
| 14 | export type { ReaperActionId } from "./Reaper/actions.ts"; | 15 | export type { ReaperActionId } from "./Reaper/actions.ts"; |
| ... | @@ -37,6 +38,108 @@ export interface ReaperTransportState { | ... | @@ -37,6 +38,108 @@ export interface ReaperTransportState { |
| 37 | source: "osc" | "optimistic"; | 38 | source: "osc" | "optimistic"; |
| 38 | } | 39 | } |
| 39 | | 40 | |
| | 41 | /** Writable per-field signals mirroring {@link ReaperTransportState} (minus `readAtMs`). */ |
| | 42 | type 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 | */ |
| | 54 | export 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 | |
| | 83 | type 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 | */ |
| | 97 | export 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. */ |
| | 118 | const 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. */ |
| | 137 | interface FeedbackSnapshot { |
| | 138 | transport: ReaperTransportPatch; |
| | 139 | extras: Partial<ReaperExtraState>; |
| | 140 | toggles: Map<number, boolean>; |
| | 141 | } |
| | 142 | |
| 40 | type ReaperScriptName = string; | 143 | type ReaperScriptName = string; |
| 41 | | 144 | |
| 42 | type OscScalar = number | string | boolean; | 145 | type OscScalar = number | string | boolean; |
| ... | @@ -105,7 +208,17 @@ const REAPER_SCRIPT_TARGET_DIR = process.env.REAPER_SCRIPTS_DIR | ... | @@ -105,7 +208,17 @@ const REAPER_SCRIPT_TARGET_DIR = process.env.REAPER_SCRIPTS_DIR |
| 105 | const REAPER_FEEDBACK_DIR = join(REAPER_SCRIPT_TARGET_DIR, "scripts"); | 208 | const REAPER_FEEDBACK_DIR = join(REAPER_SCRIPT_TARGET_DIR, "scripts"); |
| 106 | const REAPER_FEEDBACK_FILE = "state.json"; | 209 | const REAPER_FEEDBACK_FILE = "state.json"; |
| 107 | const REAPER_FEEDBACK_STATE_PATH = join(REAPER_FEEDBACK_DIR, REAPER_FEEDBACK_FILE); | 210 | const 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. |
| | 213 | const REAPER_WATCH_STATE_PATH = join(REAPER_FEEDBACK_DIR, "watch.json"); |
| 108 | const REAPER_FEEDBACK_SCRIPT = "clover_feedback"; | 214 | const 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. |
| | 221 | const REAPER_FEEDBACK_POLL_INTERVAL_MS = 250; |
| 109 | const REAPER_OSC_TARGET_DIR = process.env.REAPER_OSC_DIR | 222 | const REAPER_OSC_TARGET_DIR = process.env.REAPER_OSC_DIR |
| 110 | ?? DEFAULT_REAPER_OSC_TARGET_DIR; | 223 | ?? DEFAULT_REAPER_OSC_TARGET_DIR; |
| 111 | const OSC_PATTERN_FILE = "CloverAutomation.ReaperOSC"; | 224 | const OSC_PATTERN_FILE = "CloverAutomation.ReaperOSC"; |
| ... | @@ -148,6 +261,14 @@ const execFileAsync = promisify(execFile); | ... | @@ -148,6 +261,14 @@ const execFileAsync = promisify(execFile); |
| 148 | | 261 | |
| 149 | export class Reaper extends Events<Reaper.EventMap> { | 262 | export class Reaper extends Events<Reaper.EventMap> { |
| 150 | #transport = blankTransportState(); | 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; |
| 151 | #pendingScrubDelta = 0; | 272 | #pendingScrubDelta = 0; |
| 152 | #scrubTimer: ReturnType<typeof setTimeout> | null = null; | 273 | #scrubTimer: ReturnType<typeof setTimeout> | null = null; |
| 153 | #warnedOscSocket = false; | 274 | #warnedOscSocket = false; |
| ... | @@ -160,10 +281,12 @@ export class Reaper extends Events<Reaper.EventMap> { | ... | @@ -160,10 +281,12 @@ export class Reaper extends Events<Reaper.EventMap> { |
| 160 | #sendSocket: Socket; | 281 | #sendSocket: Socket; |
| 161 | #closed = false; | 282 | #closed = false; |
| 162 | #feedbackWatcher: FSWatcher | null = null; | 283 | #feedbackWatcher: FSWatcher | null = null; |
| | 284 | #feedbackPollTimer: ReturnType<typeof setInterval> | null = null; |
| 163 | #feedbackLaunched = false; | 285 | #feedbackLaunched = false; |
| 164 | | 286 | |
| 165 | constructor(options: ReaperOptions = {}) { | 287 | constructor(options: ReaperOptions = {}) { |
| 166 | super(); | 288 | super(); |
| | 289 | this.#state = this.#buildState(); |
| 167 | this.#oscHost = options.oscHost ?? DEFAULT_REAPER_OSC_HOST; | 290 | this.#oscHost = options.oscHost ?? DEFAULT_REAPER_OSC_HOST; |
| 168 | this.#oscPort = options.oscPort ?? DEFAULT_REAPER_OSC_PORT; | 291 | this.#oscPort = options.oscPort ?? DEFAULT_REAPER_OSC_PORT; |
| 169 | this.#oscBindPort = options.oscBindPort ?? DEFAULT_REAPER_OSC_BIND_PORT; | 292 | this.#oscBindPort = options.oscBindPort ?? DEFAULT_REAPER_OSC_BIND_PORT; |
| ... | @@ -181,6 +304,47 @@ export class Reaper extends Events<Reaper.EventMap> { | ... | @@ -181,6 +304,47 @@ export class Reaper extends Events<Reaper.EventMap> { |
| 181 | return { ...this.#transport }; | 304 | return { ...this.#transport }; |
| 182 | } | 305 | } |
| 183 | | 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 | |
| 184 | #warnIfOscSurfaceMissing() { | 348 | #warnIfOscSurfaceMissing() { |
| 185 | if (hasManagedOscSurface(readReaperConfig())) { | 349 | if (hasManagedOscSurface(readReaperConfig())) { |
| 186 | return; | 350 | return; |
| ... | @@ -205,6 +369,11 @@ export class Reaper extends Events<Reaper.EventMap> { | ... | @@ -205,6 +369,11 @@ export class Reaper extends Events<Reaper.EventMap> { |
| 205 | this.#feedbackWatcher?.close(); | 369 | this.#feedbackWatcher?.close(); |
| 206 | this.#feedbackWatcher = null; | 370 | this.#feedbackWatcher = null; |
| 207 | | 371 | |
| | 372 | if (this.#feedbackPollTimer) { |
| | 373 | clearInterval(this.#feedbackPollTimer); |
| | 374 | this.#feedbackPollTimer = null; |
| | 375 | } |
| | 376 | |
| 208 | this.#receiveSocket.removeAllListeners(); | 377 | this.#receiveSocket.removeAllListeners(); |
| 209 | this.#sendSocket.removeAllListeners(); | 378 | this.#sendSocket.removeAllListeners(); |
| 210 | closeSocket(this.#receiveSocket); | 379 | closeSocket(this.#receiveSocket); |
| ... | @@ -296,14 +465,18 @@ export class Reaper extends Events<Reaper.EventMap> { | ... | @@ -296,14 +465,18 @@ export class Reaper extends Events<Reaper.EventMap> { |
| 296 | ); | 465 | ); |
| 297 | } | 466 | } |
| 298 | | 467 | |
| 299 | // Live tempo + time signature come from a deferred REAPER Lua script that | 468 | // Live project state OSC can't report (tempo, time signature, track selection, |
| 300 | // writes them to a JSON file whenever they change; we watch that file. This | 469 | // action toggle states, ...) comes from a deferred REAPER Lua script that |
| 301 | // covers what OSC can't (REAPER has no time-signature feedback token). | 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). |
| 302 | async #startFeedback() { | 473 | async #startFeedback() { |
| 303 | try { | 474 | try { |
| 304 | await this.#ensureManagedScripts(); | 475 | await this.#ensureManagedScripts(); |
| | 476 | await this.#writeWatchList(); |
| 305 | await this.#readFeedbackState(); | 477 | await this.#readFeedbackState(); |
| 306 | this.#watchFeedbackState(); | 478 | this.#watchFeedbackState(); |
| | 479 | this.#startFeedbackPolling(); |
| 307 | await this.#launchFeedbackScript(); | 480 | await this.#launchFeedbackScript(); |
| 308 | } catch (error) { | 481 | } catch (error) { |
| 309 | this.#logFeedbackError(error); | 482 | this.#logFeedbackError(error); |
| ... | @@ -314,7 +487,10 @@ export class Reaper extends Events<Reaper.EventMap> { | ... | @@ -314,7 +487,10 @@ export class Reaper extends Events<Reaper.EventMap> { |
| 314 | if (this.#feedbackWatcher || this.#closed) return; | 487 | if (this.#feedbackWatcher || this.#closed) return; |
| 315 | try { | 488 | try { |
| 316 | const watcher = watch(REAPER_FEEDBACK_DIR, (_event, filename) => { | 489 | 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)) { |
| 318 | void this.#readFeedbackState(); | 494 | void this.#readFeedbackState(); |
| 319 | } | 495 | } |
| 320 | }); | 496 | }); |
| ... | @@ -322,10 +498,22 @@ export class Reaper extends Events<Reaper.EventMap> { | ... | @@ -322,10 +498,22 @@ export class Reaper extends Events<Reaper.EventMap> { |
| 322 | watcher.unref?.(); | 498 | watcher.unref?.(); |
| 323 | this.#feedbackWatcher = watcher; | 499 | this.#feedbackWatcher = watcher; |
| 324 | } catch { | 500 | } 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. |
| 326 | } | 502 | } |
| 327 | } | 503 | } |
| 328 | | 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 | |
| 329 | async #readFeedbackState() { | 517 | async #readFeedbackState() { |
| 330 | let raw: string; | 518 | let raw: string; |
| 331 | try { | 519 | try { |
| ... | @@ -333,10 +521,13 @@ export class Reaper extends Events<Reaper.EventMap> { | ... | @@ -333,10 +521,13 @@ export class Reaper extends Events<Reaper.EventMap> { |
| 333 | } catch { | 521 | } catch { |
| 334 | return; // not written yet | 522 | return; // not written yet |
| 335 | } | 523 | } |
| 336 | const patch = parseFeedbackState(raw); | 524 | const snapshot = parseFeedbackSnapshot(raw); |
| 337 | if (patch) { | 525 | if (!snapshot) { |
| 338 | this.#updateTransport(patch, "osc"); | 526 | return; |
| 339 | } | 527 | } |
| | 528 | this.#updateTransport(snapshot.transport, "osc"); |
| | 529 | this.#updateExtras(snapshot.extras); |
| | 530 | this.#updateToggles(snapshot.toggles); |
| 340 | } | 531 | } |
| 341 | | 532 | |
| 342 | async #launchFeedbackScript() { | 533 | async #launchFeedbackScript() { |
| ... | @@ -406,12 +597,55 @@ export class Reaper extends Events<Reaper.EventMap> { | ... | @@ -406,12 +597,55 @@ export class Reaper extends Events<Reaper.EventMap> { |
| 406 | source, | 597 | source, |
| 407 | }; | 598 | }; |
| 408 | this.#transport = next; | 599 | this.#transport = next; |
| | 600 | this.#publishState(next); |
| 409 | | 601 | |
| 410 | if (!sameTransportState(previous, next)) { | 602 | if (!sameTransportState(previous, next)) { |
| 411 | this.emit("transport", { ...next }); | 603 | this.emit("transport", { ...next }); |
| 412 | } | 604 | } |
| 413 | } | 605 | } |
| 414 | | 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 | |
| 415 | #queueScrubDelta(delta: number) { | 649 | #queueScrubDelta(delta: number) { |
| 416 | if (!Number.isFinite(delta) || delta === 0) { | 650 | if (!Number.isFinite(delta) || delta === 0) { |
| 417 | return; | 651 | return; |
| ... | @@ -517,7 +751,7 @@ export class Reaper extends Events<Reaper.EventMap> { | ... | @@ -517,7 +751,7 @@ export class Reaper extends Events<Reaper.EventMap> { |
| 517 | | 751 | |
| 518 | #logFeedbackError(error: unknown) { | 752 | #logFeedbackError(error: unknown) { |
| 519 | this.#logError( | 753 | 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}).`, |
| 521 | error, | 755 | error, |
| 522 | ); | 756 | ); |
| 523 | } | 757 | } |
| ... | @@ -551,6 +785,23 @@ function blankTransportState(): ReaperTransportState { | ... | @@ -551,6 +785,23 @@ function blankTransportState(): ReaperTransportState { |
| 551 | }; | 785 | }; |
| 552 | } | 786 | } |
| 553 | | 787 | |
| | 788 | /** Seed the state signals from the same defaults as {@link blankTransportState}. */ |
| | 789 | function 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 | |
| 554 | function sameTransportState( | 805 | function sameTransportState( |
| 555 | left: ReaperTransportState, | 806 | left: ReaperTransportState, |
| 556 | right: ReaperTransportState, | 807 | right: ReaperTransportState, |
| ... | @@ -566,22 +817,107 @@ function sameTransportState( | ... | @@ -566,22 +817,107 @@ function sameTransportState( |
| 566 | && left.timeSignature === right.timeSignature; | 817 | && left.timeSignature === right.timeSignature; |
| 567 | } | 818 | } |
| 568 | | 819 | |
| 569 | /** Parse the feedback script's `{ "tempo": <bpm>, "timesig": "n/d" }` payload. */ | 820 | /** Seed the extra-state signals with the same defaults as {@link ReaperExtraState}. */ |
| 570 | function parseFeedbackState(raw: string): ReaperTransportPatch | null { | 821 | function blankExtraSignals(): ReaperExtraSignals { |
| 571 | let data: { tempo?: unknown; timesig?: unknown }; | 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 | */ |
| | 844 | function parseFeedbackSnapshot(raw: string): FeedbackSnapshot | null { |
| | 845 | let data: Record<string, unknown>; |
| 572 | try { | 846 | try { |
| 573 | data = JSON.parse(raw); | 847 | data = JSON.parse(raw); |
| 574 | } catch { | 848 | } catch { |
| 575 | return null; | 849 | return null; |
| 576 | } | 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. |
| | 866 | function parseFeedbackTransport(data: Record<string, unknown>): ReaperTransportPatch { |
| 577 | const patch: ReaperTransportPatch = {}; | 867 | const patch: ReaperTransportPatch = {}; |
| 578 | if (typeof data.tempo === "number" && Number.isFinite(data.tempo)) { | 868 | if (isFiniteNumber(data.tempo)) patch.tempo = data.tempo; |
| 579 | 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 | |
| | 874 | function 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; |
| 580 | } | 881 | } |
| 581 | if (typeof data.timesig === "string" && data.timesig.length > 0) { | 882 | return extras as Partial<ReaperExtraState>; |
| 582 | patch.timeSignature = data.timesig; | 883 | } |
| | 884 | |
| | 885 | function 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; |
| 583 | } | 898 | } |
| 584 | return Object.keys(patch).length > 0 ? patch : null; | 899 | } |
| | 900 | |
| | 901 | function 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 | |
| | 915 | function isFiniteNumber(value: unknown): value is number { |
| | 916 | return typeof value === "number" && Number.isFinite(value); |
| | 917 | } |
| | 918 | |
| | 919 | function isNonEmptyString(value: unknown): value is string { |
| | 920 | return typeof value === "string" && value.length > 0; |
| 585 | } | 921 | } |
| 586 | | 922 | |
| 587 | function optimisticTransportPatchForAction( | 923 | function optimisticTransportPatchForAction( |