| 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). |
| 21 | if reaper.GetExtState("clover", "feedback") == "1" then |
| 22 | return |
| 23 | end |
| 24 | reaper.SetExtState("clover", "feedback", "1", false) |
| 25 | reaper.atexit(function() |
| 26 | reaper.SetExtState("clover", "feedback", "", false) |
| 27 | end) |
| 28 | |
| 29 | local source = debug.getinfo(1, "S").source |
| 30 | local script_path = source:match("^@(.+)$") |
| 31 | local script_dir = script_path:match("^(.*)[/\\].-$") |
| 32 | local sep = package.config:sub(1, 1) |
| 33 | local state_path = script_dir .. sep .. "state.json" |
| 34 | local watch_path = script_dir .. sep .. "watch.json" |
| 35 | |
| 36 | local POLL_INTERVAL = 0.1 -- seconds between real snapshots (~10 Hz) |
| 37 | local WATCH_INTERVAL = 1.0 -- seconds between re-reads of the watch list |
| 38 | |
| 39 | local 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 .. '"' |
| 44 | end |
| 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. |
| 48 | local watch_ids = {} |
| 49 | local watch_read_at = nil |
| 50 | |
| 51 | local 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 |
| 64 | end |
| 65 | |
| 66 | local 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, ",") .. "}" |
| 74 | end |
| 75 | |
| 76 | local 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 | .. "}" |
| 131 | end |
| 132 | |
| 133 | local last = nil |
| 134 | local last_run = nil |
| 135 | |
| 136 | local 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) |
| 155 | end |
| 156 | |
| 157 | poll() |