diff --git a/control/config/reaper.ts b/control/config/reaper.ts index fe308bec47c8b9d533a4d8f6d8b09a34faf03528..df050988157ffe6defbc2be83c0eef1dbd9dc87f 100644 --- a/control/config/reaper.ts +++ b/control/config/reaper.ts @@ -1,20 +1,13 @@ import * as config from "#config"; import { lucide, mdi, stack, timeSignature, txt } from "@clo/clover-control/icons"; import { Reaper } from "@clo/clover-control/Reaper"; -import { signal } from "@clo/clover-control/signals"; +import { effect } from "@clo/clover-control/signals"; export default config.forApp( "com.cockos.reaper", ({ keypad, dialpad, mac }) => { const reaper = new Reaper(); - - // Live project state — faces that read these re-render themselves on change. - const bpm = signal(120); - const timeSig = signal("4/4"); - reaper.on("transport", (t) => { - bpm.set(Math.round(t.tempo)); - timeSig.set(t.timeSignature); - }); + const { tempo, timeSignature: timeSig, playing } = reaper.state; const addInstrument = keypad.menu((menu) => { menu.key("up-left", txt("KK"), () => { @@ -47,14 +40,14 @@ export default config.forApp( }); }); - keypad.key("up-left", mdi("metronome"), () => { + keypad.key("up-left", () => mdi("metronome").fg(reaper.state.metronomeOn() ? "teal" : "white"), () => { reaper.runAction("options-toggle-metronome"); }); keypad.key("up", () => timeSignature(timeSig()), () => { - reaper.runAction("file-project-settings"); + reaper.runScript("insert_time_signature"); }); - keypad.key("up-right", () => stack(bpm(), "BPM"), () => { - reaper.runAction("tempo-increase-current-project-tempo-01-bpm"); + keypad.key("up-right", () => stack(Math.round(tempo()), "BPM"), () => { + reaper.runAction("tempo-envelope-insert-tempo-time-signature-change-marker-at-edit-cursor"); }); keypad.key("left", lucide("Plus"), () => { addInstrument.open(); @@ -62,12 +55,15 @@ export default config.forApp( keypad.key("down-left", lucide("Mic").fg("red"), () => { reaper.runAction("transport-record"); }); - keypad.key("down-right", lucide("Play"), () => { + keypad.key("down-right", () => lucide(playing() ? "Pause" : "Play"), () => { reaper.runAction("transport-play-stop"); }); + keypad.key("center", () => mdi("magnet").fg(reaper.state.snapOn() ? "teal" : "white"), () => { + reaper.runAction("options-toggle-snapping"); + }); - reaper.on("transport", (transport) => { - recording.active = transport.recording; + effect(() => { + recording.active = reaper.state.recording(); }); dialpad.on("rotate", (delta) => { diff --git a/control/config/reaper/scripts/clover_feedback.lua b/control/config/reaper/scripts/clover_feedback.lua index 32e218ba7f84baed9b2017ad6ca95586dfd5e3a8..7e14f2298edbb60ac08eccb0bd715b2c03dd0ebb 100644 --- a/control/config/reaper/scripts/clover_feedback.lua +++ b/control/config/reaper/scripts/clover_feedback.lua @@ -1,10 +1,20 @@ --- Clover live feedback: continuously write the project's tempo and time --- signature to state.json (next to this script) so the Clover Node process can --- watch the file and update the keypad. Re-schedules itself via reaper.defer, --- writing only when a value actually changes. +-- Clover live feedback: write a snapshot of REAPER/project state to state.json +-- (next to this script) so the Clover Node process can watch the file and drive +-- the keypad. Re-schedules itself via reaper.defer, but only does real work at +-- ~10 Hz and only writes (via an atomic temp-file rename) when the snapshot +-- actually changes — so an idle or merely-playing project writes nothing. -- --- REAPER's OSC has a tempo token but no time-signature feedback, so we read both --- here in one place (reaper.TimeMap_GetTimeSigAtTime returns num, denom, tempo). +-- This carries only *low-frequency* state: things a human changes (tempo, time +-- signature, track selection, master volume, action toggle states) plus the +-- current bar number. It deliberately does NOT carry the continuous playhead — +-- that high-frequency position data belongs on OSC (event-driven, no disk), not a +-- polled file. Bar position is emitted by edge detection: we compute the measure +-- each tick but only write when it changes, so during playback the file updates +-- once per measure (on the downbeat), not on every timestamp. +-- +-- The set of action command IDs whose toggle state we report is read from +-- watch.json, a list the Node side maintains, so new toggle signals need no edit +-- to this script. -- Avoid stacking multiple defer loops if the script gets launched again (e.g. a -- Clover restart while REAPER keeps running). @@ -21,31 +31,124 @@ local script_path = source:match("^@(.+)$") local script_dir = script_path:match("^(.*)[/\\].-$") local sep = package.config:sub(1, 1) local state_path = script_dir .. sep .. "state.json" +local watch_path = script_dir .. sep .. "watch.json" -local last = nil +local POLL_INTERVAL = 0.1 -- seconds between real snapshots (~10 Hz) +local WATCH_INTERVAL = 1.0 -- seconds between re-reads of the watch list + +local function json_string(value) + value = tostring(value or "") + value = value:gsub("\\", "\\\\"):gsub('"', '\\"') + :gsub("\n", "\\n"):gsub("\r", "\\r"):gsub("\t", "\\t") + return '"' .. value .. '"' +end + +-- Cache the watched command IDs; the Node side rewrites watch.json only when a +-- signal is added, so re-reading it once a second is plenty responsive. +local watch_ids = {} +local watch_read_at = nil + +local function refresh_watch_ids(now) + if watch_read_at and (now - watch_read_at) < WATCH_INTERVAL then return end + watch_read_at = now + local ids = {} + local file = io.open(watch_path, "r") + if file then + local content = file:read("*a") + file:close() + for id in content:gmatch("%d+") do + ids[#ids + 1] = tonumber(id) + end + end + watch_ids = ids +end + +local function toggles_json() + local parts = {} + for _, id in ipairs(watch_ids) do + -- GetToggleCommandState: -1 unknown, 0 off, 1 on. + local state = reaper.GetToggleCommandState(id) + parts[#parts + 1] = string.format('"%d":%d', id, state == 1 and 1 or 0) + end + return "{" .. table.concat(parts, ",") .. "}" +end local function snapshot() - local position - if reaper.GetPlayState() > 0 then - position = reaper.GetPlayPosition() - else - position = reaper.GetCursorPosition() - end + local edit_pos = reaper.GetCursorPosition() -- moves on click, not during playback + -- Follow the playhead while transport is rolling, else the edit cursor. Only + -- the *measure* derived from this is emitted, so it changes at bar boundaries. + local pos = reaper.GetPlayState() > 0 and reaper.GetPlayPosition() or edit_pos + local _, measure_index = reaper.TimeMap2_timeToBeats(0, pos) + local measure = math.floor(measure_index) + 1 -- TimeMap2 measures are 0-based - local num, denom, tempo = reaper.TimeMap_GetTimeSigAtTime(0, position) + local num, denom = reaper.TimeMap_GetTimeSigAtTime(0, pos) num = math.floor(num + 0.5) denom = math.floor(denom + 0.5) - return string.format('{"tempo":%.3f,"timesig":"%d/%d"}', tempo, num, denom) + local tempo = reaper.Master_GetTempo() + + local repeat_on = reaper.GetSetRepeat(-1) + local proj_len = reaper.GetProjectLength(0) + + local sel_start, sel_end = reaper.GetSet_LoopTimeRange(false, false, 0, 0, false) + local sel_active = sel_end > sel_start + + local track_count = reaper.CountTracks(0) + local sel_track_count = reaper.CountSelectedTracks(0) + local sel_name, sel_index = "", 0 + local sel_track = reaper.GetSelectedTrack(0, 0) + if sel_track then + local _, name = reaper.GetTrackName(sel_track) + sel_name = name or "" + sel_index = math.floor(reaper.GetMediaTrackInfo_Value(sel_track, "IP_TRACKNUMBER") + 0.5) + end + + local master = reaper.GetMasterTrack(0) + local vol = reaper.GetMediaTrackInfo_Value(master, "D_VOL") + local vol_db = vol > 0 and (20 * math.log(vol) / math.log(10)) or -150 + + local proj_name = reaper.GetProjectName(0, "") + local dirty = reaper.IsProjectDirty(0) + + return "{" + .. string.format('"tempo":%.3f', tempo) + .. string.format(',"timesig":"%d/%d"', num, denom) + .. string.format(',"repeat":%d', repeat_on) + .. string.format(',"measure":%d', measure) + .. string.format(',"editCursor":%.3f', edit_pos) + .. string.format(',"projectLength":%.3f', proj_len) + .. string.format(',"timeSelStart":%.3f', sel_start) + .. string.format(',"timeSelEnd":%.3f', sel_end) + .. string.format(',"timeSelActive":%d', sel_active and 1 or 0) + .. string.format(',"trackCount":%d', track_count) + .. string.format(',"selTrackCount":%d', sel_track_count) + .. ',"selTrackName":' .. json_string(sel_name) + .. string.format(',"selTrackIndex":%d', sel_index) + .. string.format(',"masterVolDb":%.2f', vol_db) + .. ',"projectName":' .. json_string(proj_name) + .. string.format(',"projectDirty":%d', dirty) + .. ',"toggles":' .. toggles_json() + .. "}" end +local last = nil +local last_run = nil + local function poll() - local snap = snapshot() - if snap ~= last then - last = snap - local file = io.open(state_path, "w") - if file then - file:write(snap) - file:close() + local now = reaper.time_precise() + -- Defer runs at UI framerate (~30 Hz); only do real work every POLL_INTERVAL. + if not last_run or (now - last_run) >= POLL_INTERVAL then + last_run = now + refresh_watch_ids(now) + local ok, snap = pcall(snapshot) + if ok and snap ~= last then + last = snap + local tmp = state_path .. ".tmp" + local file = io.open(tmp, "w") + if file then + file:write(snap) + file:close() + os.rename(tmp, state_path) -- atomic swap so readers never see a torn write + end end end reaper.defer(poll) diff --git a/control/config/reaper/scripts/insert_time_signature.lua b/control/config/reaper/scripts/insert_time_signature.lua new file mode 100644 index 0000000000000000000000000000000000000000..8cb14578a9aec8cb9d7c616f074c6a4399b0d08b --- /dev/null +++ b/control/config/reaper/scripts/insert_time_signature.lua @@ -0,0 +1,24 @@ +-- Insert a time-signature change at the edit cursor, then open REAPER's native +-- tempo/time-signature dialog to edit it. +-- +-- The bare Shift+C action (40256) opens that dialog with the "Time signature" +-- box UNchecked, so you have to tick it every time. Pre-inserting a marker that +-- already carries a time signature means the dialog opens in EDIT mode with the +-- box already checked (and tempo left alone) — the effect we actually want. + +local proj = 0 +local cursor = reaper.GetCursorPositionEx(proj) + +-- Inherit the time signature currently in effect so we edit it, not clobber it. +local num, denom = reaper.TimeMap_GetTimeSigAtTime(proj, cursor) + +reaper.Undo_BeginBlock() +-- ptidx=-1 insert new | timepos=cursor | measure/beat=-1 | bpm=-1 leave tempo as-is +reaper.SetTempoTimeSigMarker(proj, -1, cursor, -1, -1, -1, num, denom, false) +reaper.Undo_EndBlock("Insert time signature marker", -1) +reaper.UpdateTimeline() + +-- 40256 = "Tempo envelope: Insert tempo/time signature change marker at edit +-- cursor" (the action bound to Shift+C). With a marker already at the cursor it +-- opens in edit mode, time-signature enabled. +reaper.Main_OnCommand(40256, 0) diff --git a/control/src/Reaper.ts b/control/src/Reaper.ts index f77a5c2208e18a29683261c09bb07a057d7d77f7..3f57e6ecc142147913bf2d9e189d40607f74c69b 100644 --- a/control/src/Reaper.ts +++ b/control/src/Reaper.ts @@ -9,6 +9,7 @@ import process from "node:process"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; import { REAPER_ACTIONS, type ReaperActionId } from "./Reaper/actions.ts"; +import { type ReadonlySignal, signal, type Signal } from "./signals.ts"; const console = log.scoped("reaper"); export type { ReaperActionId } from "./Reaper/actions.ts"; @@ -37,6 +38,108 @@ export interface ReaperTransportState { source: "osc" | "optimistic"; } +/** Writable per-field signals mirroring {@link ReaperTransportState} (minus `readAtMs`). */ +type ReaperStateSignals = { + [K in Exclude]: Signal< + ReaperTransportState[K] + >; +}; + +/** + * Extra live project state that OSC can't report — sourced from the feedback Lua + * script (see config/reaper/scripts/clover_feedback.lua). Add a field here, add + * the matching row to {@link EXTRA_FIELDS}, and have the Lua script emit the same + * JSON key — that's the whole recipe for a new signal. + */ +export interface ReaperExtraState { + /** Current bar number (1-based) — ticks over on each downbeat during playback. */ + currentMeasure: number; + /** Edit-cursor position in seconds. */ + editCursorSeconds: number; + /** Total project length in seconds. */ + projectLengthSeconds: number; + /** Time/loop selection start in seconds. */ + timeSelectionStart: number; + /** Time/loop selection end in seconds. */ + timeSelectionEnd: number; + /** Whether a non-empty time/loop selection exists. */ + timeSelectionActive: boolean; + /** Number of tracks in the project. */ + trackCount: number; + /** Number of currently selected tracks. */ + selectedTrackCount: number; + /** Name of the first selected track (empty if none). */ + selectedTrackName: string; + /** 1-based index of the first selected track (0 if none). */ + selectedTrackIndex: number; + /** Master track volume in dB. */ + masterVolumeDb: number; + /** Project file name (empty for an unsaved project). */ + projectName: string; + /** Whether the project has unsaved changes. */ + projectDirty: boolean; +} + +type ReaperExtraSignals = { + [K in keyof ReaperExtraState]: Signal; +}; + +/** + * Live project state as fine-grained signals. Reading one inside a reactive + * scope (an `effect`, `computed`, or keypad face thunk) subscribes to it, so the + * scope re-runs whenever just that field changes. This is the reactive twin of + * the {@link Reaper.transport} snapshot / `"transport"` event. + * + * Beyond the transport + {@link ReaperExtraState} fields, `toggle(actionId)` + * returns a signal for the on/off state of *any* toggleable REAPER action — the + * "infinite" escape hatch. A few common ones are pre-named for convenience. + */ +export type ReaperState = + & { readonly [K in keyof ReaperStateSignals]: ReadonlySignal } + & { readonly [K in keyof ReaperExtraState]: ReadonlySignal } + & { + /** Whether the metronome is enabled (`options-toggle-metronome`). */ + readonly metronomeOn: ReadonlySignal; + /** Whether snapping is enabled (`options-toggle-snapping`). */ + readonly snapOn: ReadonlySignal; + /** Whether pre-roll before playback is enabled. */ + readonly preRollOnPlay: ReadonlySignal; + /** Whether pre-roll before recording is enabled. */ + readonly preRollOnRecord: ReadonlySignal; + /** + * A signal for any action's toggle (on/off) state. The first call for an + * action starts watching it (REAPER reports it on the next feedback tick, so + * it may read `false` briefly). Repeated calls return the same signal. + */ + toggle(actionId: ReaperActionId): ReadonlySignal; + }; + +/** Maps each {@link ReaperExtraState} field to its JSON key + how to coerce it. */ +const EXTRA_FIELDS: ReadonlyArray< + readonly [keyof ReaperExtraState, string, "number" | "string" | "boolean"] +> = [ + ["currentMeasure", "measure", "number"], + ["editCursorSeconds", "editCursor", "number"], + ["projectLengthSeconds", "projectLength", "number"], + ["timeSelectionStart", "timeSelStart", "number"], + ["timeSelectionEnd", "timeSelEnd", "number"], + ["timeSelectionActive", "timeSelActive", "boolean"], + ["trackCount", "trackCount", "number"], + ["selectedTrackCount", "selTrackCount", "number"], + ["selectedTrackName", "selTrackName", "string"], + ["selectedTrackIndex", "selTrackIndex", "number"], + ["masterVolumeDb", "masterVolDb", "number"], + ["projectName", "projectName", "string"], + ["projectDirty", "projectDirty", "boolean"], +]; + +/** A parsed feedback snapshot, split by how each part is applied. */ +interface FeedbackSnapshot { + transport: ReaperTransportPatch; + extras: Partial; + toggles: Map; +} + type ReaperScriptName = string; type OscScalar = number | string | boolean; @@ -105,7 +208,17 @@ const REAPER_SCRIPT_TARGET_DIR = process.env.REAPER_SCRIPTS_DIR const REAPER_FEEDBACK_DIR = join(REAPER_SCRIPT_TARGET_DIR, "scripts"); const REAPER_FEEDBACK_FILE = "state.json"; const REAPER_FEEDBACK_STATE_PATH = join(REAPER_FEEDBACK_DIR, REAPER_FEEDBACK_FILE); +// The Node side writes the set of action command IDs whose toggle state it wants +// reported here; the feedback script reads it each tick. See #writeWatchList. +const REAPER_WATCH_STATE_PATH = join(REAPER_FEEDBACK_DIR, "watch.json"); const REAPER_FEEDBACK_SCRIPT = "clover_feedback"; +// fs.watch on macOS (FSEvents) coalesces the feedback script's temp-write + +// atomic rename and can drop or mislabel the resulting event, so a change to a +// file-only field (tempo, time signature) can go unseen until the next event +// that happens to be delivered cleanly. Poll the state file as a reliability +// backstop; reads are idempotent and the signals dedupe, so an unchanged poll +// costs a readFile + JSON.parse and nothing else. +const REAPER_FEEDBACK_POLL_INTERVAL_MS = 250; const REAPER_OSC_TARGET_DIR = process.env.REAPER_OSC_DIR ?? DEFAULT_REAPER_OSC_TARGET_DIR; const OSC_PATTERN_FILE = "CloverAutomation.ReaperOSC"; @@ -148,6 +261,14 @@ const execFileAsync = promisify(execFile); export class Reaper extends Events { #transport = blankTransportState(); + readonly #transportSignals = blankStateSignals(); + readonly #extraSignals = blankExtraSignals(); + // Toggle signals keyed by action command ID; #toggleByAction dedupes lookups + // so calling toggle() twice for the same action returns the same signal. + readonly #toggleSignals = new Map>(); + readonly #toggleByAction = new Map>(); + readonly #state: ReaperState; + #watchWriteScheduled = false; #pendingScrubDelta = 0; #scrubTimer: ReturnType | null = null; #warnedOscSocket = false; @@ -160,10 +281,12 @@ export class Reaper extends Events { #sendSocket: Socket; #closed = false; #feedbackWatcher: FSWatcher | null = null; + #feedbackPollTimer: ReturnType | null = null; #feedbackLaunched = false; constructor(options: ReaperOptions = {}) { super(); + this.#state = this.#buildState(); this.#oscHost = options.oscHost ?? DEFAULT_REAPER_OSC_HOST; this.#oscPort = options.oscPort ?? DEFAULT_REAPER_OSC_PORT; this.#oscBindPort = options.oscBindPort ?? DEFAULT_REAPER_OSC_BIND_PORT; @@ -181,6 +304,47 @@ export class Reaper extends Events { return { ...this.#transport }; } + /** + * Live project state as per-field signals (tempo, time signature, transport + * flags, playhead, track selection, toggle states, ...). Read them inside a + * keypad face thunk (or any reactive scope) to have it re-render whenever that + * field changes: + * + * keypad.key("up-right", () => stack(Math.round(reaper.state.tempo()), "BPM"), ...); + * keypad.key("up-left", () => mdi("metronome").fg(reaper.state.metronomeOn() ? "amber" : "grey"), ...); + */ + get state(): ReaperState { + return this.#state; + } + + #buildState(): ReaperState { + return { + ...this.#transportSignals, + ...this.#extraSignals, + metronomeOn: this.#registerToggle("options-toggle-metronome"), + snapOn: this.#registerToggle("options-toggle-snapping"), + preRollOnPlay: this.#registerToggle("pre-roll-toggle-pre-roll-on-play"), + preRollOnRecord: this.#registerToggle("pre-roll-toggle-pre-roll-on-record"), + toggle: (actionId) => this.#registerToggle(actionId), + }; + } + + // Return the signal tracking `actionId`'s toggle state, creating (and starting + // to watch) it on first request. The feedback script reports every watched ID. + #registerToggle(actionId: ReaperActionId): ReadonlySignal { + const existing = this.#toggleByAction.get(actionId); + if (existing) return existing; + + const commandId = REAPER_ACTIONS[actionId]; + const created = this.#toggleSignals.get(commandId) ?? signal(false); + if (commandId !== undefined) { + this.#toggleSignals.set(commandId, created); + this.#scheduleWatchListWrite(); + } + this.#toggleByAction.set(actionId, created); + return created; + } + #warnIfOscSurfaceMissing() { if (hasManagedOscSurface(readReaperConfig())) { return; @@ -205,6 +369,11 @@ export class Reaper extends Events { this.#feedbackWatcher?.close(); this.#feedbackWatcher = null; + if (this.#feedbackPollTimer) { + clearInterval(this.#feedbackPollTimer); + this.#feedbackPollTimer = null; + } + this.#receiveSocket.removeAllListeners(); this.#sendSocket.removeAllListeners(); closeSocket(this.#receiveSocket); @@ -296,14 +465,18 @@ export class Reaper extends Events { ); } - // Live tempo + time signature come from a deferred REAPER Lua script that - // writes them to a JSON file whenever they change; we watch that file. This - // covers what OSC can't (REAPER has no time-signature feedback token). + // Live project state OSC can't report (tempo, time signature, track selection, + // action toggle states, ...) comes from a deferred REAPER Lua script that + // writes a JSON snapshot whenever it changes; we watch that file and fan it out + // into the state signals. The set of toggles it reports is driven by the watch + // list we write here (see #writeWatchList). async #startFeedback() { try { await this.#ensureManagedScripts(); + await this.#writeWatchList(); await this.#readFeedbackState(); this.#watchFeedbackState(); + this.#startFeedbackPolling(); await this.#launchFeedbackScript(); } catch (error) { this.#logFeedbackError(error); @@ -314,7 +487,10 @@ export class Reaper extends Events { if (this.#feedbackWatcher || this.#closed) return; try { const watcher = watch(REAPER_FEEDBACK_DIR, (_event, filename) => { - if (!filename || filename === REAPER_FEEDBACK_FILE) { + // Re-read on any event naming the state file OR its temp sibling: the + // atomic-rename write touches both, and FSEvents may deliver only the + // `.tmp` name. A null filename (event with no name) re-reads too. + if (!filename || filename.startsWith(REAPER_FEEDBACK_FILE)) { void this.#readFeedbackState(); } }); @@ -322,10 +498,22 @@ export class Reaper extends Events { watcher.unref?.(); this.#feedbackWatcher = watcher; } catch { - // Directory may not be watchable; the periodic writes still land via reads. + // Directory may not be watchable; the polling backstop still reads it. } } + // Backstop for missed/coalesced fs.watch events (see the interval constant): + // re-read the state file on a slow interval so tempo/time-signature changes + // always converge even when the watcher doesn't fire for them. + #startFeedbackPolling() { + if (this.#feedbackPollTimer || this.#closed) return; + const timer = setInterval(() => { + void this.#readFeedbackState(); + }, REAPER_FEEDBACK_POLL_INTERVAL_MS); + timer.unref?.(); + this.#feedbackPollTimer = timer; + } + async #readFeedbackState() { let raw: string; try { @@ -333,10 +521,13 @@ export class Reaper extends Events { } catch { return; // not written yet } - const patch = parseFeedbackState(raw); - if (patch) { - this.#updateTransport(patch, "osc"); + const snapshot = parseFeedbackSnapshot(raw); + if (!snapshot) { + return; } + this.#updateTransport(snapshot.transport, "osc"); + this.#updateExtras(snapshot.extras); + this.#updateToggles(snapshot.toggles); } async #launchFeedbackScript() { @@ -406,12 +597,55 @@ export class Reaper extends Events { source, }; this.#transport = next; + this.#publishState(next); if (!sameTransportState(previous, next)) { this.emit("transport", { ...next }); } } + // Push the new snapshot into the transport signals. Each `set` is a no-op when + // the value is unchanged, so a reactive scope only re-runs for the fields it + // actually reads that actually moved. + #publishState(next: ReaperTransportState) { + const signals = this.#transportSignals as Record>; + for (const key of Object.keys(signals)) { + signals[key].set((next as Record)[key]); + } + } + + #updateExtras(extras: Partial) { + for (const key of Object.keys(extras) as (keyof ReaperExtraState)[]) { + (this.#extraSignals[key] as Signal).set(extras[key]); + } + } + + #updateToggles(toggles: Map) { + for (const [commandId, on] of toggles) { + this.#toggleSignals.get(commandId)?.set(on); + } + } + + // Coalesce a burst of toggle registrations into one write of the watch list. + #scheduleWatchListWrite() { + if (this.#watchWriteScheduled || this.#closed) return; + this.#watchWriteScheduled = true; + queueMicrotask(() => { + this.#watchWriteScheduled = false; + void this.#writeWatchList(); + }); + } + + async #writeWatchList() { + try { + await this.#ensureManagedScripts(); + const ids = [...this.#toggleSignals.keys()]; + await writeFile(REAPER_WATCH_STATE_PATH, JSON.stringify(ids)); + } catch { + // Best-effort: toggles just won't be reported until a later write lands. + } + } + #queueScrubDelta(delta: number) { if (!Number.isFinite(delta) || delta === 0) { return; @@ -517,7 +751,7 @@ export class Reaper extends Events { #logFeedbackError(error: unknown) { this.#logError( - `Failed to start live tempo/time-signature feedback (${REAPER_FEEDBACK_STATE_PATH}).`, + `Failed to start live project-state feedback (${REAPER_FEEDBACK_STATE_PATH}).`, error, ); } @@ -551,6 +785,23 @@ function blankTransportState(): ReaperTransportState { }; } +/** Seed the state signals from the same defaults as {@link blankTransportState}. */ +function blankStateSignals(): ReaperStateSignals { + const initial = blankTransportState(); + return { + playing: signal(initial.playing), + paused: signal(initial.paused), + recording: signal(initial.recording), + repeatOn: signal(initial.repeatOn), + positionSeconds: signal(initial.positionSeconds), + positionString: signal(initial.positionString), + positionBeatsString: signal(initial.positionBeatsString), + tempo: signal(initial.tempo), + timeSignature: signal(initial.timeSignature), + source: signal(initial.source), + }; +} + function sameTransportState( left: ReaperTransportState, right: ReaperTransportState, @@ -566,22 +817,107 @@ function sameTransportState( && left.timeSignature === right.timeSignature; } -/** Parse the feedback script's `{ "tempo": , "timesig": "n/d" }` payload. */ -function parseFeedbackState(raw: string): ReaperTransportPatch | null { - let data: { tempo?: unknown; timesig?: unknown }; +/** Seed the extra-state signals with the same defaults as {@link ReaperExtraState}. */ +function blankExtraSignals(): ReaperExtraSignals { + return { + currentMeasure: signal(1), + editCursorSeconds: signal(0), + projectLengthSeconds: signal(0), + timeSelectionStart: signal(0), + timeSelectionEnd: signal(0), + timeSelectionActive: signal(false), + trackCount: signal(0), + selectedTrackCount: signal(0), + selectedTrackName: signal(""), + selectedTrackIndex: signal(0), + masterVolumeDb: signal(0), + projectName: signal(""), + projectDirty: signal(false), + }; +} + +/** + * Parse the feedback script's JSON snapshot into the three ways we apply it: + * a transport patch, the extra-state fields, and the action toggle map. Tolerant + * of missing/garbage keys — anything unrecognized is simply skipped. + */ +function parseFeedbackSnapshot(raw: string): FeedbackSnapshot | null { + let data: Record; try { data = JSON.parse(raw); } catch { return null; } + if (typeof data !== "object" || data === null) { + return null; + } + return { + transport: parseFeedbackTransport(data), + extras: parseFeedbackExtras(data), + toggles: parseFeedbackToggles(data.toggles), + }; +} + +// Only low-frequency, human-driven fields come from the feedback file. The +// playhead position deliberately does not — it belongs on OSC. So the transport +// signals positionSeconds/positionString/positionBeatsString stay OSC/optimistic +// only (unused until OSC position tokens are added), and this file never rewrites +// on playback. +function parseFeedbackTransport(data: Record): ReaperTransportPatch { const patch: ReaperTransportPatch = {}; - if (typeof data.tempo === "number" && Number.isFinite(data.tempo)) { - patch.tempo = data.tempo; + if (isFiniteNumber(data.tempo)) patch.tempo = data.tempo; + if (isNonEmptyString(data.timesig)) patch.timeSignature = data.timesig; + if (typeof data.repeat === "number") patch.repeatOn = data.repeat !== 0; + return patch; +} + +function parseFeedbackExtras( + data: Record, +): Partial { + const extras: Record = {}; + for (const [key, source, kind] of EXTRA_FIELDS) { + const value = coerceExtra(data[source], kind); + if (value !== undefined) extras[key] = value; } - if (typeof data.timesig === "string" && data.timesig.length > 0) { - patch.timeSignature = data.timesig; + return extras as Partial; +} + +function coerceExtra( + value: unknown, + kind: "number" | "string" | "boolean", +): number | string | boolean | undefined { + switch (kind) { + case "number": + return isFiniteNumber(value) ? value : undefined; + case "string": + return typeof value === "string" ? value : undefined; + case "boolean": + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + return undefined; } - return Object.keys(patch).length > 0 ? patch : null; +} + +function parseFeedbackToggles(value: unknown): Map { + const toggles = new Map(); + if (typeof value !== "object" || value === null) { + return toggles; + } + for (const [key, raw] of Object.entries(value as Record)) { + const commandId = Number(key); + if (Number.isInteger(commandId)) { + toggles.set(commandId, raw === 1 || raw === true); + } + } + return toggles; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; } function optimisticTransportPatchForAction( diff --git a/control/src/icons.ts b/control/src/icons.ts index 7e38979534f5e2e255cca9baf7d2472570951564..51f59f0756c558169d1402dad3e03ca5682d6690 100644 --- a/control/src/icons.ts +++ b/control/src/icons.ts @@ -252,7 +252,13 @@ function glyph(inner: string, size: number, style: GlyphStyle): string { } const SANS_FONT = "Helvetica, Arial, sans-serif"; -const SERIF_FONT = "Georgia, 'Times New Roman', Times, serif"; +// Lead with a lining-figures serif. Georgia (and most "text" serifs) use +// old-style figures where digits sit at different heights — 0/1/2 are short, +// 3/4/5/7/9 descend, 6/8 ascend — so no single vertical offset can center +// every numerator/denominator pair. Times uses lining figures: all digits +// share one baseline and cap-height, which is also how music engraving sets +// time signatures. +const SERIF_FONT = "'Times New Roman', Times, Georgia, serif"; interface TextStyle { family?: string; @@ -305,10 +311,14 @@ function stackInner(lines: readonly StackSpec[], fg: string): string { // Four horizontal staff lines with the serif numerals stacked across them — // numerator in the upper half, denominator in the lower, like real sheet music. -const STAFF_LINES = 4; -const STAFF_GAP = 12; +const STAFF_LINES = 5; +const STAFF_GAP = 16; const STAFF_INSET = 16; const TIMESIG_GLYPH = 40; +// Vertical distance from the middle staff line to each numeral's center. The +// numerator sits this far above the center, the denominator the same distance +// below, so the pair is balanced regardless of which digits are shown. +const TIMESIG_STACK_OFFSET = 16; function timeSignatureInner( numerator: string, @@ -325,9 +335,10 @@ function timeSignatureInner( } const style: TextStyle = { family: SERIF_FONT, weight: 700 }; const numeral = TIMESIG_GLYPH; + const middle = KEY_SIZE / 2; const glyphs = - textAt(KEY_SIZE / 2, KEY_SIZE / 2 - 16, numerator, fg, numeral, style) + - textAt(KEY_SIZE / 2, KEY_SIZE / 2 + 16, denominator, fg, numeral, style); + textAt(KEY_SIZE / 2, middle - TIMESIG_STACK_OFFSET, numerator, fg, numeral, style) + + textAt(KEY_SIZE / 2, middle + TIMESIG_STACK_OFFSET, denominator, fg, numeral, style); return staff + glyphs; }