| ... | ... | @@ -9,6 +9,7 @@ import process from "node:process"; |
| 9 | 9 | import { fileURLToPath } from "node:url"; |
| 10 | 10 | import { promisify } from "node:util"; |
| 11 | 11 | import { REAPER_ACTIONS, type ReaperActionId } from "./Reaper/actions.ts"; |
| 12 | import { type ReadonlySignal, signal, type Signal } from "./signals.ts"; |
| 12 | 13 | const console = log.scoped("reaper"); |
| 13 | 14 | |
| 14 | 15 | export type { ReaperActionId } from "./Reaper/actions.ts"; |
| ... | ... | @@ -37,6 +38,108 @@ export interface ReaperTransportState { |
| 37 | 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 | 143 | type ReaperScriptName = string; |
| 41 | 144 | |
| 42 | 145 | type OscScalar = number | string | boolean; |
| ... | ... | @@ -105,7 +208,17 @@ const REAPER_SCRIPT_TARGET_DIR = process.env.REAPER_SCRIPTS_DIR |
| 105 | 208 | const REAPER_FEEDBACK_DIR = join(REAPER_SCRIPT_TARGET_DIR, "scripts"); |
| 106 | 209 | const REAPER_FEEDBACK_FILE = "state.json"; |
| 107 | 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 | 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 | 222 | const REAPER_OSC_TARGET_DIR = process.env.REAPER_OSC_DIR |
| 110 | 223 | ?? DEFAULT_REAPER_OSC_TARGET_DIR; |
| 111 | 224 | const OSC_PATTERN_FILE = "CloverAutomation.ReaperOSC"; |
| ... | ... | @@ -148,6 +261,14 @@ const execFileAsync = promisify(execFile); |
| 148 | 261 | |
| 149 | 262 | export class Reaper extends Events<Reaper.EventMap> { |
| 150 | 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 | 272 | #pendingScrubDelta = 0; |
| 152 | 273 | #scrubTimer: ReturnType<typeof setTimeout> | null = null; |
| 153 | 274 | #warnedOscSocket = false; |
| ... | ... | @@ -160,10 +281,12 @@ export class Reaper extends Events<Reaper.EventMap> { |
| 160 | 281 | #sendSocket: Socket; |
| 161 | 282 | #closed = false; |
| 162 | 283 | #feedbackWatcher: FSWatcher | null = null; |
| 284 | #feedbackPollTimer: ReturnType<typeof setInterval> | null = null; |
| 163 | 285 | #feedbackLaunched = false; |
| 164 | 286 | |
| 165 | 287 | constructor(options: ReaperOptions = {}) { |
| 166 | 288 | super(); |
| 289 | this.#state = this.#buildState(); |
| 167 | 290 | this.#oscHost = options.oscHost ?? DEFAULT_REAPER_OSC_HOST; |
| 168 | 291 | this.#oscPort = options.oscPort ?? DEFAULT_REAPER_OSC_PORT; |
| 169 | 292 | this.#oscBindPort = options.oscBindPort ?? DEFAULT_REAPER_OSC_BIND_PORT; |
| ... | ... | @@ -181,6 +304,47 @@ export class Reaper extends Events<Reaper.EventMap> { |
| 181 | 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 | 348 | #warnIfOscSurfaceMissing() { |
| 185 | 349 | if (hasManagedOscSurface(readReaperConfig())) { |
| 186 | 350 | return; |
| ... | ... | @@ -205,6 +369,11 @@ export class Reaper extends Events<Reaper.EventMap> { |
| 205 | 369 | this.#feedbackWatcher?.close(); |
| 206 | 370 | this.#feedbackWatcher = null; |
| 207 | 371 | |
| 372 | if (this.#feedbackPollTimer) { |
| 373 | clearInterval(this.#feedbackPollTimer); |
| 374 | this.#feedbackPollTimer = null; |
| 375 | } |
| 376 | |
| 208 | 377 | this.#receiveSocket.removeAllListeners(); |
| 209 | 378 | this.#sendSocket.removeAllListeners(); |
| 210 | 379 | closeSocket(this.#receiveSocket); |
| ... | ... | @@ -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 |
| 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). |
| 302 | 473 | async #startFeedback() { |
| 303 | 474 | try { |
| 304 | 475 | await this.#ensureManagedScripts(); |
| 476 | await this.#writeWatchList(); |
| 305 | 477 | await this.#readFeedbackState(); |
| 306 | 478 | this.#watchFeedbackState(); |
| 479 | this.#startFeedbackPolling(); |
| 307 | 480 | await this.#launchFeedbackScript(); |
| 308 | 481 | } catch (error) { |
| 309 | 482 | this.#logFeedbackError(error); |
| ... | ... | @@ -314,7 +487,10 @@ export class Reaper extends Events<Reaper.EventMap> { |
| 314 | 487 | if (this.#feedbackWatcher || this.#closed) return; |
| 315 | 488 | try { |
| 316 | 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 | 494 | void this.#readFeedbackState(); |
| 319 | 495 | } |
| 320 | 496 | }); |
| ... | ... | @@ -322,10 +498,22 @@ export class Reaper extends Events<Reaper.EventMap> { |
| 322 | 498 | watcher.unref?.(); |
| 323 | 499 | this.#feedbackWatcher = watcher; |
| 324 | 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 | 517 | async #readFeedbackState() { |
| 330 | 518 | let raw: string; |
| 331 | 519 | try { |
| ... | ... | @@ -333,10 +521,13 @@ export class Reaper extends Events<Reaper.EventMap> { |
| 333 | 521 | } catch { |
| 334 | 522 | return; // not written yet |
| 335 | 523 | } |
| 336 | | const patch = parseFeedbackState(raw); |
| 337 | | if (patch) { |
| 338 | | this.#updateTransport(patch, "osc"); |
| 524 | const snapshot = parseFeedbackSnapshot(raw); |
| 525 | if (!snapshot) { |
| 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 | 533 | async #launchFeedbackScript() { |
| ... | ... | @@ -406,12 +597,55 @@ export class Reaper extends Events<Reaper.EventMap> { |
| 406 | 597 | source, |
| 407 | 598 | }; |
| 408 | 599 | this.#transport = next; |
| 600 | this.#publishState(next); |
| 409 | 601 | |
| 410 | 602 | if (!sameTransportState(previous, next)) { |
| 411 | 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 | 649 | #queueScrubDelta(delta: number) { |
| 416 | 650 | if (!Number.isFinite(delta) || delta === 0) { |
| 417 | 651 | return; |
| ... | ... | @@ -517,7 +751,7 @@ export class Reaper extends Events<Reaper.EventMap> { |
| 517 | 751 | |
| 518 | 752 | #logFeedbackError(error: unknown) { |
| 519 | 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 | 755 | error, |
| 522 | 756 | ); |
| 523 | 757 | } |
| ... | ... | @@ -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 | 805 | function sameTransportState( |
| 555 | 806 | left: ReaperTransportState, |
| 556 | 807 | right: ReaperTransportState, |
| ... | ... | @@ -566,22 +817,107 @@ function sameTransportState( |
| 566 | 817 | && left.timeSignature === right.timeSignature; |
| 567 | 818 | } |
| 568 | 819 | |
| 569 | | /** Parse the feedback script's `{ "tempo": <bpm>, "timesig": "n/d" }` payload. */ |
| 570 | | function 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}. */ |
| 821 | function 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 | */ |
| 844 | function parseFeedbackSnapshot(raw: string): FeedbackSnapshot | null { |
| 845 | let data: Record<string, unknown>; |
| 572 | 846 | try { |
| 573 | 847 | data = JSON.parse(raw); |
| 574 | 848 | } catch { |
| 575 | 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 | 867 | 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 | |
| 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) { |
| 582 | | patch.timeSignature = data.timesig; |
| 882 | return extras as Partial<ReaperExtraState>; |
| 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 | 923 | function optimisticTransportPatchForAction( |