1-- Clover live feedback: write a snapshot of REAPER/project state to state.json
2-- (next to this script) so the Clover Node process can watch the file and drive
3-- the keypad. Re-schedules itself via reaper.defer, but only does real work at
4-- ~10 Hz and only writes (via an atomic temp-file rename) when the snapshot
5-- actually changes — so an idle or merely-playing project writes nothing.
6--
7-- This carries only *low-frequency* state: things a human changes (tempo, time
8-- signature, track selection, master volume, action toggle states) plus the
9-- current bar number. It deliberately does NOT carry the continuous playhead —
10-- that high-frequency position data belongs on OSC (event-driven, no disk), not a
11-- polled file. Bar position is emitted by edge detection: we compute the measure
12-- each tick but only write when it changes, so during playback the file updates
13-- once per measure (on the downbeat), not on every timestamp.
14--
15-- The set of action command IDs whose toggle state we report is read from
16-- watch.json, a list the Node side maintains, so new toggle signals need no edit
17-- to this script.
18
19-- Avoid stacking multiple defer loops if the script gets launched again (e.g. a
20-- Clover restart while REAPER keeps running).
21if reaper.GetExtState("clover", "feedback") == "1" then
22 return
23end
24reaper.SetExtState("clover", "feedback", "1", false)
25reaper.atexit(function()
26 reaper.SetExtState("clover", "feedback", "", false)
27end)
28
29local source = debug.getinfo(1, "S").source
30local script_path = source:match("^@(.+)$")
31local script_dir = script_path:match("^(.*)[/\\].-$")
32local sep = package.config:sub(1, 1)
33local state_path = script_dir .. sep .. "state.json"
34local watch_path = script_dir .. sep .. "watch.json"
35
36local POLL_INTERVAL = 0.1 -- seconds between real snapshots (~10 Hz)
37local WATCH_INTERVAL = 1.0 -- seconds between re-reads of the watch list
38
39local function json_string(value)
40 value = tostring(value or "")
41 value = value:gsub("\\", "\\\\"):gsub('"', '\\"')
42 :gsub("\n", "\\n"):gsub("\r", "\\r"):gsub("\t", "\\t")
43 return '"' .. value .. '"'
44end
45
46-- Cache the watched command IDs; the Node side rewrites watch.json only when a
47-- signal is added, so re-reading it once a second is plenty responsive.
48local watch_ids = {}
49local watch_read_at = nil
50
51local function refresh_watch_ids(now)
52 if watch_read_at and (now - watch_read_at) < WATCH_INTERVAL then return end
53 watch_read_at = now
54 local ids = {}
55 local file = io.open(watch_path, "r")
56 if file then
57 local content = file:read("*a")
58 file:close()
59 for id in content:gmatch("%d+") do
60 ids[#ids + 1] = tonumber(id)
61 end
62 end
63 watch_ids = ids
64end
65
66local function toggles_json()
67 local parts = {}
68 for _, id in ipairs(watch_ids) do
69 -- GetToggleCommandState: -1 unknown, 0 off, 1 on.
70 local state = reaper.GetToggleCommandState(id)
71 parts[#parts + 1] = string.format('"%d":%d', id, state == 1 and 1 or 0)
72 end
73 return "{" .. table.concat(parts, ",") .. "}"
74end
75
76local function snapshot()
77 local edit_pos = reaper.GetCursorPosition() -- moves on click, not during playback
78 -- Follow the playhead while transport is rolling, else the edit cursor. Only
79 -- the *measure* derived from this is emitted, so it changes at bar boundaries.
80 local pos = reaper.GetPlayState() > 0 and reaper.GetPlayPosition() or edit_pos
81 local _, measure_index = reaper.TimeMap2_timeToBeats(0, pos)
82 local measure = math.floor(measure_index) + 1 -- TimeMap2 measures are 0-based
83
84 local num, denom = reaper.TimeMap_GetTimeSigAtTime(0, pos)
85 num = math.floor(num + 0.5)
86 denom = math.floor(denom + 0.5)
87 local tempo = reaper.Master_GetTempo()
88
89 local repeat_on = reaper.GetSetRepeat(-1)
90 local proj_len = reaper.GetProjectLength(0)
91
92 local sel_start, sel_end = reaper.GetSet_LoopTimeRange(false, false, 0, 0, false)
93 local sel_active = sel_end > sel_start
94
95 local track_count = reaper.CountTracks(0)
96 local sel_track_count = reaper.CountSelectedTracks(0)
97 local sel_name, sel_index = "", 0
98 local sel_track = reaper.GetSelectedTrack(0, 0)
99 if sel_track then
100 local _, name = reaper.GetTrackName(sel_track)
101 sel_name = name or ""
102 sel_index = math.floor(reaper.GetMediaTrackInfo_Value(sel_track, "IP_TRACKNUMBER") + 0.5)
103 end
104
105 local master = reaper.GetMasterTrack(0)
106 local vol = reaper.GetMediaTrackInfo_Value(master, "D_VOL")
107 local vol_db = vol > 0 and (20 * math.log(vol) / math.log(10)) or -150
108
109 local proj_name = reaper.GetProjectName(0, "")
110 local dirty = reaper.IsProjectDirty(0)
111
112 return "{"
113 .. string.format('"tempo":%.3f', tempo)
114 .. string.format(',"timesig":"%d/%d"', num, denom)
115 .. string.format(',"repeat":%d', repeat_on)
116 .. string.format(',"measure":%d', measure)
117 .. string.format(',"editCursor":%.3f', edit_pos)
118 .. string.format(',"projectLength":%.3f', proj_len)
119 .. string.format(',"timeSelStart":%.3f', sel_start)
120 .. string.format(',"timeSelEnd":%.3f', sel_end)
121 .. string.format(',"timeSelActive":%d', sel_active and 1 or 0)
122 .. string.format(',"trackCount":%d', track_count)
123 .. string.format(',"selTrackCount":%d', sel_track_count)
124 .. ',"selTrackName":' .. json_string(sel_name)
125 .. string.format(',"selTrackIndex":%d', sel_index)
126 .. string.format(',"masterVolDb":%.2f', vol_db)
127 .. ',"projectName":' .. json_string(proj_name)
128 .. string.format(',"projectDirty":%d', dirty)
129 .. ',"toggles":' .. toggles_json()
130 .. "}"
131end
132
133local last = nil
134local last_run = nil
135
136local function poll()
137 local now = reaper.time_precise()
138 -- Defer runs at UI framerate (~30 Hz); only do real work every POLL_INTERVAL.
139 if not last_run or (now - last_run) >= POLL_INTERVAL then
140 last_run = now
141 refresh_watch_ids(now)
142 local ok, snap = pcall(snapshot)
143 if ok and snap ~= last then
144 last = snap
145 local tmp = state_path .. ".tmp"
146 local file = io.open(tmp, "w")
147 if file then
148 file:write(snap)
149 file:close()
150 os.rename(tmp, state_path) -- atomic swap so readers never see a torn write
151 end
152 end
153 end
154 reaper.defer(poll)
155end
156
157poll()