@mthines/reaper-mcp 0.19.0 → 0.70.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -570
- package/bridge-ui/mcp_bridge.html +129 -0
- package/bridge-ui/mcp_bridge.webmanifest +16 -0
- package/bridge-ui/mcp_bridge_icon_192.png +0 -0
- package/bridge-ui/mcp_bridge_icon_512.png +0 -0
- package/bridge-ui/mcp_bridge_sw.js +35 -0
- package/claude/agents/plugin-researcher.md +188 -0
- package/claude/knowledge/reference/aesthetics-scoring.md +48 -0
- package/{knowledge → claude/knowledge}/reference/common-mistakes.md +1 -1
- package/claude/knowledge/reference/lorekit-setup.md +36 -0
- package/claude/knowledge/reference/memory-protocol.md +377 -0
- package/{knowledge → claude/knowledge}/reference/metering.md +5 -5
- package/claude/knowledge/reference/perceived-loudness.md +182 -0
- package/{knowledge → claude/knowledge}/workflows/editing.md +4 -0
- package/{knowledge → claude/knowledge}/workflows/gain-staging.md +21 -23
- package/{knowledge → claude/knowledge}/workflows/learn-plugin.md +15 -48
- package/{knowledge → claude/knowledge}/workflows/master-bus.md +13 -1
- package/claude/knowledge/workflows/section-levels.md +138 -0
- package/{knowledge → claude/knowledge}/workflows/session-prep.md +4 -0
- package/claude/knowledge/workflows/transient-lighting.md +52 -0
- package/claude/skills/critique/SKILL.md +131 -0
- package/claude/skills/editing/SKILL.md +281 -0
- package/claude/skills/learn-plugin/SKILL.md +210 -0
- package/claude/skills/mastering/SKILL.md +151 -0
- package/claude/skills/mix-memory/SKILL.md +140 -0
- package/claude/skills/mixer/SKILL.md +253 -0
- package/claude/skills/new-extension/SKILL.md +324 -0
- package/main.js +7843 -1940
- package/package.json +17 -15
- package/reaper/CLAUDE.md +22 -4
- package/reaper/mcp_actions_lib.lua +375 -0
- package/reaper/mcp_analyzer.jsfx +27 -12
- package/reaper/mcp_arrange_lib.lua +949 -0
- package/reaper/mcp_bridge.lua +2321 -758
- package/reaper/mcp_edit_views_lib.lua +1378 -0
- package/reaper/mcp_envelopes_lib.lua +748 -0
- package/reaper/mcp_extensions_lib.lua +55 -0
- package/reaper/mcp_fxstate_lib.lua +144 -0
- package/reaper/mcp_gain_trace.jsfx +126 -0
- package/reaper/mcp_items_lib.lua +877 -0
- package/reaper/mcp_lufs_meter.jsfx +16 -13
- package/reaper/mcp_midi_lib.lua +555 -0
- package/reaper/mcp_mixing_lib.lua +1021 -0
- package/reaper/mcp_plan_lib.lua +801 -0
- package/reaper/mcp_prefs_lib.lua +127 -0
- package/reaper/mcp_project_lib.lua +1088 -0
- package/reaper/mcp_recording_lib.lua +595 -0
- package/reaper/mcp_render_lib.lua +492 -0
- package/reaper/mcp_snapshot_lib.lua +547 -120
- package/reaper/mcp_snapshot_next.lua +10 -4
- package/reaper/mcp_snapshot_prev.lua +11 -4
- package/reaper/mcp_snapshot_quick_save.lua +7 -32
- package/reaper/mcp_snapshots_ui_lib.lua +258 -0
- package/reaper/mcp_tracks_lib.lua +409 -0
- package/sidecar/analysis.py +661 -0
- package/sidecar/chords.py +96 -0
- package/sidecar/mixcheck.py +295 -0
- package/sidecar/requirements-chords.txt +6 -0
- package/sidecar/requirements-separation.txt +3 -0
- package/sidecar/requirements.txt +17 -0
- package/sidecar/separation.py +142 -0
- package/sidecar/server.py +622 -0
- package/sidecar/tests/test_analysis.py +249 -0
- package/sidecar/tests/test_chords.py +117 -0
- package/sidecar/tests/test_mixcheck.py +163 -0
- package/sidecar/tests/test_separation.py +73 -0
- package/sidecar/tests/test_server.py +464 -0
- package/sidecar/tests/test_verify.py +243 -0
- package/sidecar/tests/test_voice_render.py +268 -0
- package/sidecar/tests/test_voice_sketch.py +140 -0
- package/sidecar/verify.py +224 -0
- package/sidecar/voice_render.py +295 -0
- package/sidecar/voice_sketch.py +280 -0
- package/claude-agents/critique.md +0 -147
- package/claude-agents/editor.md +0 -184
- package/claude-agents/levels.md +0 -107
- package/claude-agents/mastering.md +0 -129
- package/claude-agents/mixer.md +0 -248
- package/claude-agents/preflight.md +0 -149
- package/claude-agents/producer.md +0 -159
- package/claude-agents/setup.md +0 -150
- package/claude-agents/stems.md +0 -135
- package/claude-rules/architecture.md +0 -39
- package/claude-rules/development.md +0 -54
- package/claude-rules/lua-bridge.md +0 -50
- package/claude-rules/testing.md +0 -42
- package/claude-skills/learn-plugin.md +0 -123
- package/knowledge/CLAUDE.md +0 -71
- package/knowledge/plugins/_template.md +0 -82
- package/knowledge/plugins/fabfilter/pro-c-2.md +0 -117
- package/knowledge/plugins/fabfilter/pro-l-2.md +0 -95
- package/knowledge/plugins/fabfilter/pro-q-3.md +0 -112
- package/knowledge/plugins/neural-dsp/helix-native.md +0 -104
- package/knowledge/plugins/stock-reaper/js-1175-compressor.md +0 -94
- package/knowledge/plugins/stock-reaper/rea-comp.md +0 -100
- package/knowledge/plugins/stock-reaper/rea-delay.md +0 -95
- package/knowledge/plugins/stock-reaper/rea-eq.md +0 -103
- package/knowledge/plugins/stock-reaper/rea-gate.md +0 -99
- package/knowledge/plugins/stock-reaper/rea-limit.md +0 -75
- package/knowledge/plugins/stock-reaper/rea-verb.md +0 -76
- package/knowledge/reference/perceived-loudness.md +0 -122
- package/reaper/mcp_snapshot_manager.lua +0 -1001
- /package/{knowledge → claude/knowledge}/genres/_template.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/electronic.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/hip-hop.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/metal.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/orchestral.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/pop.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/rock.md +0 -0
- /package/{knowledge → claude/knowledge}/reference/compression.md +0 -0
- /package/{knowledge → claude/knowledge}/reference/frequencies.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/delivery.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/drum-bus.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/low-end.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/stem-prep.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/vocal-chain.md +0 -0
package/reaper/mcp_bridge.lua
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
local POLL_INTERVAL = 0 -- scan every defer cycle; the notify file makes this cheap
|
|
13
13
|
local HEARTBEAT_INTERVAL = 1.0 -- write heartbeat every 1s
|
|
14
14
|
local MCP_ANALYZER_FX_NAME = "reaper-mcp/mcp_analyzer" -- JSFX analyzer name
|
|
15
|
+
local OPEN_UI_ON_START = true -- open the status window (reaper_www_root/mcp_bridge.html) when the bridge starts
|
|
15
16
|
|
|
16
17
|
-- Determine bridge directory (REAPER resource path / Scripts / mcp_bridge_data)
|
|
17
18
|
local bridge_dir = reaper.GetResourcePath() .. "/Scripts/mcp_bridge_data/"
|
|
@@ -19,6 +20,54 @@ local bridge_dir = reaper.GetResourcePath() .. "/Scripts/mcp_bridge_data/"
|
|
|
19
20
|
-- Ensure bridge directory exists
|
|
20
21
|
reaper.RecursiveCreateDirectory(bridge_dir, 0)
|
|
21
22
|
|
|
23
|
+
-- Plan executor library (run_plan), installed next to this script by `reaper-mcp setup`.
|
|
24
|
+
-- Loaded under pcall so an older install without the file keeps every other tool working.
|
|
25
|
+
local script_dir = debug.getinfo(1, "S").source:match("@?(.*[\\/])") or ""
|
|
26
|
+
local plan_lib_ok, plan_lib = pcall(dofile, script_dir .. "mcp_plan_lib.lua")
|
|
27
|
+
-- Action proxy + selection setters (search/get/run_action, set_selected_*), same install rules.
|
|
28
|
+
local actions_lib_ok, actions_lib = pcall(dofile, script_dir .. "mcp_actions_lib.lua")
|
|
29
|
+
-- Track structure + routing setters (add/delete/move track, sends, groups), same install rules.
|
|
30
|
+
local tracks_lib_ok, tracks_lib = pcall(dofile, script_dir .. "mcp_tracks_lib.lua")
|
|
31
|
+
|
|
32
|
+
-- Track index -1 means the master track, for every tool that takes a
|
|
33
|
+
-- trackIndex. REAPER's own GetTrack returns nil for -1, so nothing relied on it.
|
|
34
|
+
do
|
|
35
|
+
local get_track = reaper.GetTrack
|
|
36
|
+
reaper.GetTrack = function(proj, idx)
|
|
37
|
+
if idx == -1 then return reaper.GetMasterTrack(proj) end
|
|
38
|
+
return get_track(proj, idx)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
-- Project ops, tempo map and marker editing (undo/redo, save, render, settings), same install rules.
|
|
42
|
+
local project_lib_ok, project_lib = pcall(dofile, script_dir .. "mcp_project_lib.lua")
|
|
43
|
+
-- Items, takes, take FX, FX move/copy/rename, .RfxChain and track templates, same install rules.
|
|
44
|
+
local items_lib_ok, items_lib = pcall(dofile, script_dir .. "mcp_items_lib.lua")
|
|
45
|
+
-- Record setup (arm, input, monitoring, record mode), fixed lanes and comping, same install rules.
|
|
46
|
+
-- One local (the main chunk is near Lua's 200-locals limit): the lib, or { load_error = why }.
|
|
47
|
+
-- Lib wrappers added since the main chunk reached the 200-locals limit live here, one field each
|
|
48
|
+
local ext = {}
|
|
49
|
+
ext.recording = (function()
|
|
50
|
+
local ok, lib = pcall(dofile, script_dir .. "mcp_recording_lib.lua")
|
|
51
|
+
if ok then return lib end
|
|
52
|
+
return { load_error = tostring(lib) }
|
|
53
|
+
end)()
|
|
54
|
+
ext.fxstate = (function()
|
|
55
|
+
local ok, lib = pcall(dofile, script_dir .. "mcp_fxstate_lib.lua")
|
|
56
|
+
if ok then return lib end
|
|
57
|
+
return { load_error = tostring(lib) }
|
|
58
|
+
end)()
|
|
59
|
+
ext.extensions = (function()
|
|
60
|
+
local ok, lib = pcall(dofile, script_dir .. "mcp_extensions_lib.lua")
|
|
61
|
+
if ok then return lib end
|
|
62
|
+
return { load_error = tostring(lib) }
|
|
63
|
+
end)()
|
|
64
|
+
-- What a write changed and what a listing showed, for the status page's drawer (same install rules).
|
|
65
|
+
local edit_lib_ok, edit_lib = pcall(dofile, script_dir .. "mcp_edit_views_lib.lua")
|
|
66
|
+
-- Snapshot list, restore and save from the status page (same install rules).
|
|
67
|
+
local snaps_lib_ok, snaps_lib = pcall(dofile, script_dir .. "mcp_snapshots_ui_lib.lua")
|
|
68
|
+
-- LoreKit tokens shared through ~/.config/reaper-mcp (same install rules).
|
|
69
|
+
ext.prefs = (function() local ok, lib = pcall(dofile, script_dir .. "mcp_prefs_lib.lua"); return ok and lib or nil end)()
|
|
70
|
+
|
|
22
71
|
local last_poll = 0
|
|
23
72
|
local last_heartbeat = 0
|
|
24
73
|
local defer_count = 0
|
|
@@ -53,6 +102,15 @@ local function extract_json_string(str, pos)
|
|
|
53
102
|
result[#result + 1] = '\t'
|
|
54
103
|
elseif next_ch == '/' then
|
|
55
104
|
result[#result + 1] = '/'
|
|
105
|
+
elseif next_ch == 'r' then
|
|
106
|
+
result[#result + 1] = '\r'
|
|
107
|
+
elseif next_ch == 'b' then
|
|
108
|
+
result[#result + 1] = '\b'
|
|
109
|
+
elseif next_ch == 'f' then
|
|
110
|
+
result[#result + 1] = '\f'
|
|
111
|
+
elseif next_ch == 'u' and str:sub(i + 2, i + 5):match('^%x%x%x%x$') then
|
|
112
|
+
result[#result + 1] = utf8.char(tonumber(str:sub(i + 2, i + 5), 16))
|
|
113
|
+
i = i + 4
|
|
56
114
|
else
|
|
57
115
|
result[#result + 1] = next_ch
|
|
58
116
|
end
|
|
@@ -67,6 +125,29 @@ local function extract_json_string(str, pos)
|
|
|
67
125
|
return nil, i -- unterminated string
|
|
68
126
|
end
|
|
69
127
|
|
|
128
|
+
-- The balanced {...} or [...] that starts at `pos`. Unlike %b{} / %b[], it
|
|
129
|
+
-- skips brackets inside strings, so a "}" in a name doesn't end the object.
|
|
130
|
+
local function balanced(str, pos, open, close)
|
|
131
|
+
local pat = open == "{" and '[\\"{}]' or '[\\"%[%]]'
|
|
132
|
+
local depth, in_str, i = 0, false, pos
|
|
133
|
+
while true do
|
|
134
|
+
i = str:find(pat, i)
|
|
135
|
+
if not i then return nil end
|
|
136
|
+
local c = str:sub(i, i)
|
|
137
|
+
if in_str then
|
|
138
|
+
if c == "\\" then i = i + 1 elseif c == '"' then in_str = false end
|
|
139
|
+
elseif c == '"' then
|
|
140
|
+
in_str = true
|
|
141
|
+
elseif c == open then
|
|
142
|
+
depth = depth + 1
|
|
143
|
+
elseif c == close then
|
|
144
|
+
depth = depth - 1
|
|
145
|
+
if depth == 0 then return str:sub(pos, i) end
|
|
146
|
+
end
|
|
147
|
+
i = i + 1
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
70
151
|
-- Parse a flat JSON object with string/number values in params.
|
|
71
152
|
-- Handles escaped quotes in string values (e.g. JSON arrays passed as strings).
|
|
72
153
|
local function parse_flat_object(str)
|
|
@@ -99,7 +180,7 @@ local function parse_flat_object(str)
|
|
|
99
180
|
end
|
|
100
181
|
elseif ch == '{' then
|
|
101
182
|
-- Nested object — extract with balanced braces
|
|
102
|
-
local nested = str
|
|
183
|
+
local nested = balanced(str, val_start, "{", "}")
|
|
103
184
|
if nested then
|
|
104
185
|
obj[key] = parse_flat_object(nested)
|
|
105
186
|
i = val_start + #nested
|
|
@@ -108,7 +189,7 @@ local function parse_flat_object(str)
|
|
|
108
189
|
end
|
|
109
190
|
elseif ch == '[' then
|
|
110
191
|
-- Array — extract with balanced brackets
|
|
111
|
-
local arr = str
|
|
192
|
+
local arr = balanced(str, val_start, "[", "]")
|
|
112
193
|
if arr then
|
|
113
194
|
obj[key] = arr -- keep as string for json_decode_array to parse later
|
|
114
195
|
i = val_start + #arr
|
|
@@ -117,7 +198,8 @@ local function parse_flat_object(str)
|
|
|
117
198
|
end
|
|
118
199
|
elseif ch:match('[%d%-]') then
|
|
119
200
|
-- Number
|
|
120
|
-
local num_str = str:match('
|
|
201
|
+
local num_str = str:match('^-?%d+%.?%d*[eE][-+]?%d+', val_start)
|
|
202
|
+
or str:match('^-?%d+%.?%d*', val_start)
|
|
121
203
|
if num_str then
|
|
122
204
|
obj[key] = tonumber(num_str)
|
|
123
205
|
i = val_start + #num_str
|
|
@@ -166,20 +248,40 @@ local function json_decode_array(input)
|
|
|
166
248
|
|
|
167
249
|
-- Fallback: extract each {...} from the array and parse individually
|
|
168
250
|
local arr = {}
|
|
169
|
-
|
|
251
|
+
local i = 1
|
|
252
|
+
while true do
|
|
253
|
+
local s = input:find("{", i, true)
|
|
254
|
+
local obj_str = s and balanced(input, s, "{", "}")
|
|
255
|
+
if not obj_str then break end
|
|
170
256
|
arr[#arr + 1] = parse_flat_object(obj_str)
|
|
257
|
+
i = s + #obj_str
|
|
171
258
|
end
|
|
172
|
-
if #arr > 0 then return arr end
|
|
259
|
+
if #arr > 0 or input:match("^%s*%[%s*%]%s*$") then return arr end
|
|
173
260
|
return nil
|
|
174
261
|
end
|
|
175
262
|
|
|
263
|
+
-- JSON forbids raw control characters in strings. One tab in a track or FX
|
|
264
|
+
-- name would otherwise make every response that carries it unparseable.
|
|
265
|
+
local JSON_ESCAPES = {
|
|
266
|
+
['"'] = '\\"', ['\\'] = '\\\\', ['\b'] = '\\b', ['\f'] = '\\f',
|
|
267
|
+
['\n'] = '\\n', ['\r'] = '\\r', ['\t'] = '\\t',
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
local function json_quote(s)
|
|
271
|
+
-- Explicit byte range, not %c: %c follows the C locale and, under a UTF-8
|
|
272
|
+
-- locale, also matches some bytes >= 0x80 (0x80-0x9F on macOS), which are
|
|
273
|
+
-- UTF-8 continuation bytes ("Æ" is C3 86) and would mangle names.
|
|
274
|
+
return '"' .. s:gsub('[\0-\31\127"\\]', function(c)
|
|
275
|
+
return JSON_ESCAPES[c] or string.format('\\u%04x', c:byte())
|
|
276
|
+
end) .. '"'
|
|
277
|
+
end
|
|
278
|
+
|
|
176
279
|
local function json_encode(obj)
|
|
177
280
|
-- Simple JSON encoder for our response objects
|
|
178
|
-
local parts = {}
|
|
179
281
|
local function encode_value(v)
|
|
180
282
|
local t = type(v)
|
|
181
283
|
if t == "string" then
|
|
182
|
-
return
|
|
284
|
+
return json_quote(v)
|
|
183
285
|
elseif t == "number" then
|
|
184
286
|
if v ~= v then return "null" end -- NaN
|
|
185
287
|
if v == math.huge or v == -math.huge then return "null" end
|
|
@@ -199,7 +301,7 @@ local function json_encode(obj)
|
|
|
199
301
|
-- Object
|
|
200
302
|
local items = {}
|
|
201
303
|
for k, val in pairs(v) do
|
|
202
|
-
items[#items + 1] =
|
|
304
|
+
items[#items + 1] = json_quote(tostring(k)) .. ':' .. encode_value(val)
|
|
203
305
|
end
|
|
204
306
|
return "{" .. table.concat(items, ",") .. "}"
|
|
205
307
|
end
|
|
@@ -457,7 +559,7 @@ function handlers.get_project_info(params)
|
|
|
457
559
|
local proj_path = reaper.GetProjectPath()
|
|
458
560
|
local track_count = reaper.CountTracks(0)
|
|
459
561
|
local tempo = reaper.Master_GetTempo()
|
|
460
|
-
local
|
|
562
|
+
local ts_num, ts_den = reaper.TimeMap_GetTimeSigAtTime(0, 0)
|
|
461
563
|
local sr = reaper.GetSetProjectInfo(0, "PROJECT_SRATE", 0, false)
|
|
462
564
|
local play_state = reaper.GetPlayState() -- 0=stopped, 1=playing, 2=paused, 4=recording
|
|
463
565
|
local cursor_pos = reaper.GetCursorPosition()
|
|
@@ -588,6 +690,18 @@ function handlers.set_track_property(params)
|
|
|
588
690
|
reaper.SetMediaTrackInfo_Value(track, "B_MUTE", value)
|
|
589
691
|
elseif prop == "solo" then
|
|
590
692
|
reaper.SetMediaTrackInfo_Value(track, "I_SOLO", value)
|
|
693
|
+
elseif prop == "recordArm" then
|
|
694
|
+
reaper.SetMediaTrackInfo_Value(track, "I_RECARM", value)
|
|
695
|
+
elseif prop == "phase" then
|
|
696
|
+
reaper.SetMediaTrackInfo_Value(track, "B_PHASE", value)
|
|
697
|
+
elseif prop == "input" then
|
|
698
|
+
reaper.SetMediaTrackInfo_Value(track, "I_RECINPUT", value)
|
|
699
|
+
elseif prop == "playOffsetMs" then
|
|
700
|
+
-- Track playback offset (REAPER 6.0+): D_PLAY_OFFSET is in seconds unless
|
|
701
|
+
-- flag &2 says samples; &1 bypasses it. Clear both so the value applies.
|
|
702
|
+
reaper.SetMediaTrackInfo_Value(track, "D_PLAY_OFFSET", value / 1000)
|
|
703
|
+
local flag = reaper.GetMediaTrackInfo_Value(track, "I_PLAY_OFFSET_FLAG")
|
|
704
|
+
reaper.SetMediaTrackInfo_Value(track, "I_PLAY_OFFSET_FLAG", flag & ~3)
|
|
591
705
|
else
|
|
592
706
|
reaper.Undo_EndBlock("MCP: Set track property", -1)
|
|
593
707
|
return nil, "Unknown property: " .. tostring(prop)
|
|
@@ -607,10 +721,15 @@ function handlers.add_fx(params)
|
|
|
607
721
|
local track = reaper.GetTrack(0, idx)
|
|
608
722
|
if not track then return nil, "Track " .. idx .. " not found" end
|
|
609
723
|
|
|
610
|
-
|
|
724
|
+
-- TrackFX_AddByName's last argument is `instantiate`, not a slot: -1 appends, 0 only
|
|
725
|
+
-- looks up an existing instance (so a new FX is "not found"), -1000 - n inserts at slot n.
|
|
726
|
+
local instantiate = -1
|
|
727
|
+
if params.position ~= nil and params.position >= 0 then
|
|
728
|
+
instantiate = -1000 - math.floor(params.position)
|
|
729
|
+
end
|
|
611
730
|
|
|
612
731
|
reaper.Undo_BeginBlock()
|
|
613
|
-
local fx_idx = reaper.TrackFX_AddByName(track, fx_name, false,
|
|
732
|
+
local fx_idx = reaper.TrackFX_AddByName(track, fx_name, false, instantiate)
|
|
614
733
|
if fx_idx < 0 then
|
|
615
734
|
reaper.Undo_EndBlock("MCP: Add FX (failed)", -1)
|
|
616
735
|
return nil, "FX not found: " .. fx_name
|
|
@@ -874,6 +993,10 @@ function handlers.read_track_meters(params)
|
|
|
874
993
|
}
|
|
875
994
|
end
|
|
876
995
|
|
|
996
|
+
-- Forward declaration: defined further below (line ~1618). Declared here so the
|
|
997
|
+
-- earlier handlers.read_track_spectrum can capture it as an upvalue.
|
|
998
|
+
local ensure_jsfx_on_track
|
|
999
|
+
|
|
877
1000
|
function handlers.read_track_spectrum(params)
|
|
878
1001
|
local idx = params.trackIndex
|
|
879
1002
|
if not idx then return nil, "trackIndex required" end
|
|
@@ -887,23 +1010,49 @@ function handlers.read_track_spectrum(params)
|
|
|
887
1010
|
return nil, err or "MCP Spectrum Analyzer JSFX not found. Run 'reaper-mcp setup' to install it."
|
|
888
1011
|
end
|
|
889
1012
|
|
|
1013
|
+
-- Apply the requested FFT size to the analyzer's slider1 (snapped to the sizes
|
|
1014
|
+
-- the JSFX supports). JSFX params via TrackFX_(Get|Set)Param use RAW slider
|
|
1015
|
+
-- values, not normalized 0-1 (verified: get_fx_parameters reports FFT Size in
|
|
1016
|
+
-- its 512..8192 range, and SetParam clamps out-of-range values to the slider
|
|
1017
|
+
-- min). Guard with GetParam so repeated polls at the same size don't reset the
|
|
1018
|
+
-- FFT accumulator (the JSFX @slider zeroes its buffers only when the size changes).
|
|
1019
|
+
local fft_size = params.fftSize or 4096
|
|
1020
|
+
fft_size = fft_size <= 512 and 512
|
|
1021
|
+
or fft_size <= 1024 and 1024
|
|
1022
|
+
or fft_size <= 2048 and 2048
|
|
1023
|
+
or fft_size <= 4096 and 4096
|
|
1024
|
+
or 8192
|
|
1025
|
+
local current_fft = reaper.TrackFX_GetParam(track, analyzer_idx, 0)
|
|
1026
|
+
if math.abs(current_fft - fft_size) > 0.5 then
|
|
1027
|
+
reaper.TrackFX_SetParam(track, analyzer_idx, 0, fft_size)
|
|
1028
|
+
end
|
|
1029
|
+
|
|
1030
|
+
-- Give this instance its own gmem block (master -1 -> slot 0), as read_track_lufs
|
|
1031
|
+
-- does; with one shared block every track's analyzer overwrote the others.
|
|
1032
|
+
local slot = idx + 1
|
|
1033
|
+
local current_slot = reaper.TrackFX_GetParam(track, analyzer_idx, 3)
|
|
1034
|
+
if math.abs(current_slot - slot) > 0.5 then
|
|
1035
|
+
reaper.TrackFX_SetParam(track, analyzer_idx, 3, slot)
|
|
1036
|
+
end
|
|
1037
|
+
|
|
890
1038
|
-- Read spectrum data from gmem
|
|
891
|
-
-- JSFX writes
|
|
1039
|
+
-- JSFX writes at base = slot * 4100: [base] = bin_count, [base+1] = peak_db,
|
|
1040
|
+
-- [base+2] = rms_db, [base+3..] = bins
|
|
892
1041
|
reaper.gmem_attach("MCPAnalyzer")
|
|
1042
|
+
local base = slot * 4100
|
|
893
1043
|
|
|
894
|
-
local bin_count = reaper.gmem_read(
|
|
1044
|
+
local bin_count = reaper.gmem_read(base)
|
|
895
1045
|
if bin_count <= 0 then
|
|
896
1046
|
return nil, "Spectrum analyzer not producing data yet. Ensure audio is playing."
|
|
897
1047
|
end
|
|
898
1048
|
|
|
899
|
-
local peak_db = reaper.gmem_read(1)
|
|
900
|
-
local rms_db = reaper.gmem_read(2)
|
|
1049
|
+
local peak_db = reaper.gmem_read(base + 1)
|
|
1050
|
+
local rms_db = reaper.gmem_read(base + 2)
|
|
901
1051
|
local sr = reaper.GetSetProjectInfo(0, "PROJECT_SRATE", 0, false)
|
|
902
|
-
local fft_size = params.fftSize or 4096
|
|
903
1052
|
|
|
904
1053
|
local bins = {}
|
|
905
1054
|
for i = 0, bin_count - 1 do
|
|
906
|
-
bins[#bins + 1] = reaper.gmem_read(3 + i)
|
|
1055
|
+
bins[#bins + 1] = reaper.gmem_read(base + 3 + i)
|
|
907
1056
|
end
|
|
908
1057
|
|
|
909
1058
|
return {
|
|
@@ -935,6 +1084,15 @@ function handlers.stop(params)
|
|
|
935
1084
|
end
|
|
936
1085
|
|
|
937
1086
|
function handlers.record(params)
|
|
1087
|
+
-- With no track armed REAPER opens a modal "Record Warning", which blocks the
|
|
1088
|
+
-- main thread and with it the bridge until a person closes it (seen in 7.40).
|
|
1089
|
+
local armed = false
|
|
1090
|
+
for i = 0, reaper.CountTracks(0) - 1 do
|
|
1091
|
+
if reaper.GetMediaTrackInfo_Value(reaper.GetTrack(0, i), "I_RECARM") ~= 0 then armed = true break end
|
|
1092
|
+
end
|
|
1093
|
+
if not armed then
|
|
1094
|
+
return nil, "No track is armed for recording (REAPER would open a dialog that blocks the bridge). Arm a track first with set_track_record or set_track_property recordArm"
|
|
1095
|
+
end
|
|
938
1096
|
-- Action 1013 = Transport: Record
|
|
939
1097
|
reaper.Main_OnCommand(1013, 0)
|
|
940
1098
|
return { success = true }
|
|
@@ -945,7 +1103,7 @@ function handlers.get_transport_state(params)
|
|
|
945
1103
|
local cursor_pos = reaper.GetCursorPosition()
|
|
946
1104
|
local play_pos = reaper.GetPlayPosition()
|
|
947
1105
|
local tempo = reaper.Master_GetTempo()
|
|
948
|
-
local
|
|
1106
|
+
local ts_num, ts_den = reaper.TimeMap_GetTimeSigAtTime(0, 0)
|
|
949
1107
|
|
|
950
1108
|
return {
|
|
951
1109
|
playing = (play_state & 1) ~= 0,
|
|
@@ -973,59 +1131,47 @@ end
|
|
|
973
1131
|
|
|
974
1132
|
-- Helper: detect FX type prefix from name
|
|
975
1133
|
local function fx_type_from_name(name)
|
|
976
|
-
|
|
977
|
-
|
|
1134
|
+
-- Instruments carry an "i" (VST3i:, VSTi:, AUi:, CLAPi:) but are the same plugin formats
|
|
1135
|
+
if name:match("^VST3i?:") then return "VST3"
|
|
1136
|
+
elseif name:match("^VSTi?:") then return "VST"
|
|
978
1137
|
elseif name:match("^JS:") then return "JS"
|
|
979
|
-
elseif name:match("^
|
|
980
|
-
elseif name:match("^
|
|
1138
|
+
elseif name:match("^AUi?:") then return "AU"
|
|
1139
|
+
elseif name:match("^CLAPi?:") then return "CLAP"
|
|
981
1140
|
else return "JS" end -- default for bare JSFX names
|
|
982
1141
|
end
|
|
983
1142
|
|
|
984
|
-
--
|
|
985
|
-
--
|
|
986
|
-
--
|
|
987
|
-
function
|
|
988
|
-
|
|
989
|
-
local
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
if not ok then break end
|
|
997
|
-
local fx_type = fx_type_from_name(name)
|
|
998
|
-
if not category_filter or fx_type:lower() == category_filter:lower() then
|
|
999
|
-
fx_list[#fx_list + 1] = {
|
|
1000
|
-
name = name,
|
|
1001
|
-
type = fx_type,
|
|
1002
|
-
path = ident or "",
|
|
1003
|
-
}
|
|
1004
|
-
end
|
|
1005
|
-
i = i + 1
|
|
1143
|
+
-- Every installed FX as REAPER lists it in the FX browser ("VST3: Pro-Q 3 (FabFilter)"), via the native
|
|
1144
|
+
-- EnumInstalledFX (REAPER 6.37+). nil when this REAPER is too old to enumerate.
|
|
1145
|
+
-- Not reaper-fxfolders.ini: that file holds the FX browser's folder layout (paths, filters), not plugins.
|
|
1146
|
+
local function enumerate_installed_fx()
|
|
1147
|
+
if type(reaper.EnumInstalledFX) ~= "function" then return nil end
|
|
1148
|
+
local list, i = {}, 0
|
|
1149
|
+
while true do
|
|
1150
|
+
local ok, name, ident = reaper.EnumInstalledFX(i)
|
|
1151
|
+
if not ok or type(name) ~= "string" or name == "" then break end
|
|
1152
|
+
-- JSFX libraries (.jsfx-inc) show up in the list but can't be loaded as effects
|
|
1153
|
+
if not (name:find("%.jsfx%-inc") or (ident and ident:find("%.jsfx%-inc"))) then
|
|
1154
|
+
list[#list + 1] = { name = name, type = fx_type_from_name(name), ident = ident or "" }
|
|
1006
1155
|
end
|
|
1007
|
-
|
|
1156
|
+
i = i + 1
|
|
1008
1157
|
end
|
|
1158
|
+
return list
|
|
1159
|
+
end
|
|
1009
1160
|
|
|
1010
|
-
|
|
1011
|
-
local
|
|
1012
|
-
local
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
end
|
|
1022
|
-
end
|
|
1161
|
+
function handlers.list_available_fx(params)
|
|
1162
|
+
local category_filter = params.category
|
|
1163
|
+
local all = enumerate_installed_fx()
|
|
1164
|
+
if not all then
|
|
1165
|
+
return { fxList = {}, total = 0, source = "none",
|
|
1166
|
+
warning = "This REAPER cannot enumerate installed FX (EnumInstalledFX needs REAPER 6.37+)." }
|
|
1167
|
+
end
|
|
1168
|
+
local fx_list = {}
|
|
1169
|
+
for _, fx in ipairs(all) do
|
|
1170
|
+
if not category_filter or fx.type:lower() == category_filter:lower() then
|
|
1171
|
+
fx_list[#fx_list + 1] = { name = fx.name, type = fx.type, path = fx.ident }
|
|
1023
1172
|
end
|
|
1024
|
-
return { fxList = fx_list, total = #fx_list, source = "fxfolders_ini" }
|
|
1025
1173
|
end
|
|
1026
|
-
|
|
1027
|
-
return { fxList = fx_list, total = 0, source = "none",
|
|
1028
|
-
warning = "SWS not installed and reaper-fxfolders.ini not found. Install SWS Extensions for full FX enumeration." }
|
|
1174
|
+
return { fxList = fx_list, total = #fx_list, source = "reaper" }
|
|
1029
1175
|
end
|
|
1030
1176
|
|
|
1031
1177
|
function handlers.search_fx(params)
|
|
@@ -1048,6 +1194,8 @@ function handlers.search_fx(params)
|
|
|
1048
1194
|
return { query = query, matches = matches, total = #matches }
|
|
1049
1195
|
end
|
|
1050
1196
|
|
|
1197
|
+
local PRESET_LIST_MAX = 256
|
|
1198
|
+
|
|
1051
1199
|
function handlers.get_fx_preset_list(params)
|
|
1052
1200
|
local track_idx = params.trackIndex
|
|
1053
1201
|
local fx_idx = params.fxIndex
|
|
@@ -1058,22 +1206,49 @@ function handlers.get_fx_preset_list(params)
|
|
|
1058
1206
|
local track = reaper.GetTrack(0, track_idx)
|
|
1059
1207
|
if not track then return nil, "Track " .. track_idx .. " not found" end
|
|
1060
1208
|
|
|
1061
|
-
local preset_count = reaper.TrackFX_GetPresetIndex(track, fx_idx)
|
|
1062
1209
|
local presets = {}
|
|
1063
1210
|
|
|
1064
|
-
--
|
|
1065
|
-
--
|
|
1066
|
-
--
|
|
1067
|
-
local
|
|
1211
|
+
-- Names are only readable by loading each preset, so the FX state is saved first and put back
|
|
1212
|
+
-- afterwards. Restoring the starting preset index alone is not enough: with no preset active
|
|
1213
|
+
-- it is -1, which loads REAPER's default preset instead of the previous settings.
|
|
1214
|
+
local current, total = reaper.TrackFX_GetPresetIndex(track, fx_idx)
|
|
1215
|
+
if total and total > PRESET_LIST_MAX then
|
|
1216
|
+
-- e.g. Kontakt's 2048 "Program N" slots: cycling them is slow, and each load can send
|
|
1217
|
+
-- program changes, so only report the current preset
|
|
1218
|
+
local _, current_preset = reaper.TrackFX_GetPreset(track, fx_idx)
|
|
1219
|
+
return {
|
|
1220
|
+
trackIndex = track_idx, fxIndex = fx_idx, total = total, truncated = true,
|
|
1221
|
+
currentPreset = current_preset,
|
|
1222
|
+
presets = {},
|
|
1223
|
+
note = "More than " .. PRESET_LIST_MAX .. " presets (usually generic program slots); not enumerated",
|
|
1224
|
+
}
|
|
1225
|
+
end
|
|
1068
1226
|
if total and total > 0 then
|
|
1227
|
+
local chunk
|
|
1228
|
+
if reaper.TrackFX_GetNamedConfigParm then
|
|
1229
|
+
local ok, c = reaper.TrackFX_GetNamedConfigParm(track, fx_idx, "vst_chunk")
|
|
1230
|
+
if ok and c ~= "" then chunk = c end
|
|
1231
|
+
end
|
|
1232
|
+
local params = {}
|
|
1233
|
+
for p = 0, reaper.TrackFX_GetNumParams(track, fx_idx) - 1 do
|
|
1234
|
+
params[p] = reaper.TrackFX_GetParamNormalized(track, fx_idx, p)
|
|
1235
|
+
end
|
|
1236
|
+
|
|
1237
|
+
-- Undo block keeps the intermediate preset loads out of the undo history
|
|
1069
1238
|
reaper.Undo_BeginBlock()
|
|
1070
1239
|
for i = 0, total - 1 do
|
|
1071
1240
|
reaper.TrackFX_SetPresetByIndex(track, fx_idx, i)
|
|
1072
1241
|
local _, preset_name = reaper.TrackFX_GetPreset(track, fx_idx)
|
|
1073
1242
|
presets[#presets + 1] = { index = i, name = preset_name or ("Preset " .. i) }
|
|
1074
1243
|
end
|
|
1075
|
-
|
|
1076
|
-
reaper.
|
|
1244
|
+
if current and current >= 0 then reaper.TrackFX_SetPresetByIndex(track, fx_idx, current) end
|
|
1245
|
+
if chunk then reaper.TrackFX_SetNamedConfigParm(track, fx_idx, "vst_chunk", chunk) end
|
|
1246
|
+
-- Params too: JSFX have no vst_chunk, and a plugin may not apply the chunk to its params
|
|
1247
|
+
for p, v in pairs(params) do
|
|
1248
|
+
if reaper.TrackFX_GetParamNormalized(track, fx_idx, p) ~= v then
|
|
1249
|
+
reaper.TrackFX_SetParamNormalized(track, fx_idx, p, v)
|
|
1250
|
+
end
|
|
1251
|
+
end
|
|
1077
1252
|
reaper.Undo_EndBlock("MCP: Get FX preset list", -1)
|
|
1078
1253
|
else
|
|
1079
1254
|
-- Plugin doesn't report preset count; return current preset only
|
|
@@ -1141,90 +1316,185 @@ local function snapshot_path(name)
|
|
|
1141
1316
|
return get_snapshot_dir() .. safe .. ".json"
|
|
1142
1317
|
end
|
|
1143
1318
|
|
|
1319
|
+
-- Base64 (standard alphabet). Used to store REAPER track state chunks inside the
|
|
1320
|
+
-- snapshot JSON: chunks contain quotes, newlines and {GUID} braces that would
|
|
1321
|
+
-- break json_encode and especially the fallback %b{} decoder. Base64's alphabet
|
|
1322
|
+
-- (A-Za-z0-9+/=) is safe for both the encoder and either decoder.
|
|
1323
|
+
local B64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
1324
|
+
local B64_LOOKUP = {}
|
|
1325
|
+
for _b = 1, #B64_CHARS do B64_LOOKUP[B64_CHARS:sub(_b, _b)] = _b - 1 end
|
|
1326
|
+
|
|
1327
|
+
local function base64_encode(data)
|
|
1328
|
+
if not data or data == "" then return "" end
|
|
1329
|
+
local out = {}
|
|
1330
|
+
local len = #data
|
|
1331
|
+
local i = 1
|
|
1332
|
+
while i <= len do
|
|
1333
|
+
local b1 = data:byte(i)
|
|
1334
|
+
local b2 = (i + 1 <= len) and data:byte(i + 1) or 0
|
|
1335
|
+
local b3 = (i + 2 <= len) and data:byte(i + 2) or 0
|
|
1336
|
+
local n = b1 * 65536 + b2 * 256 + b3
|
|
1337
|
+
local c1 = math.floor(n / 262144) % 64
|
|
1338
|
+
local c2 = math.floor(n / 4096) % 64
|
|
1339
|
+
local c3 = math.floor(n / 64) % 64
|
|
1340
|
+
local c4 = n % 64
|
|
1341
|
+
out[#out + 1] = B64_CHARS:sub(c1 + 1, c1 + 1)
|
|
1342
|
+
out[#out + 1] = B64_CHARS:sub(c2 + 1, c2 + 1)
|
|
1343
|
+
out[#out + 1] = (i + 1 <= len) and B64_CHARS:sub(c3 + 1, c3 + 1) or '='
|
|
1344
|
+
out[#out + 1] = (i + 2 <= len) and B64_CHARS:sub(c4 + 1, c4 + 1) or '='
|
|
1345
|
+
i = i + 3
|
|
1346
|
+
end
|
|
1347
|
+
return table.concat(out)
|
|
1348
|
+
end
|
|
1349
|
+
|
|
1350
|
+
local function base64_decode(data)
|
|
1351
|
+
if not data or data == "" then return "" end
|
|
1352
|
+
data = data:gsub('[^A-Za-z0-9+/]', '') -- strip padding/whitespace/newlines
|
|
1353
|
+
local out = {}
|
|
1354
|
+
local len = #data
|
|
1355
|
+
local i = 1
|
|
1356
|
+
while i <= len do
|
|
1357
|
+
local c1 = B64_LOOKUP[data:sub(i, i)] or 0
|
|
1358
|
+
local c2 = B64_LOOKUP[data:sub(i + 1, i + 1)] or 0
|
|
1359
|
+
local c3 = B64_LOOKUP[data:sub(i + 2, i + 2)]
|
|
1360
|
+
local c4 = B64_LOOKUP[data:sub(i + 3, i + 3)]
|
|
1361
|
+
local n = c1 * 262144 + c2 * 4096 + (c3 or 0) * 64 + (c4 or 0)
|
|
1362
|
+
out[#out + 1] = string.char(math.floor(n / 65536) % 256)
|
|
1363
|
+
if c3 ~= nil then out[#out + 1] = string.char(math.floor(n / 256) % 256) end
|
|
1364
|
+
if c4 ~= nil then out[#out + 1] = string.char(n % 256) end
|
|
1365
|
+
i = i + 4
|
|
1366
|
+
end
|
|
1367
|
+
return table.concat(out)
|
|
1368
|
+
end
|
|
1369
|
+
|
|
1370
|
+
-- Parse the trackIndices param, which under the fallback JSON parser arrives as a
|
|
1371
|
+
-- raw "[...]" string and under CF_Json_Parse as a Lua table. Returns a list of
|
|
1372
|
+
-- 0-based indices, or nil if not provided.
|
|
1373
|
+
local function parse_track_indices(value)
|
|
1374
|
+
if value == nil then return nil end
|
|
1375
|
+
if type(value) == "table" then
|
|
1376
|
+
local out = {}
|
|
1377
|
+
for _, v in ipairs(value) do
|
|
1378
|
+
local n = tonumber(v)
|
|
1379
|
+
if n then out[#out + 1] = math.floor(n) end
|
|
1380
|
+
end
|
|
1381
|
+
return (#out > 0) and out or nil
|
|
1382
|
+
end
|
|
1383
|
+
if type(value) == "string" then
|
|
1384
|
+
local out = {}
|
|
1385
|
+
for n in value:gmatch("%-?%d+") do out[#out + 1] = tonumber(n) end
|
|
1386
|
+
return (#out > 0) and out or nil
|
|
1387
|
+
end
|
|
1388
|
+
return nil
|
|
1389
|
+
end
|
|
1390
|
+
|
|
1144
1391
|
local function capture_mixer_state(params)
|
|
1145
1392
|
params = params or {}
|
|
1146
1393
|
local include_fx_params = params.includeFxParams ~= false -- default true
|
|
1147
1394
|
local max_params = params.maxParamsPerFx or 500
|
|
1148
|
-
|
|
1395
|
+
|
|
1396
|
+
-- Optional scope: capture only these 0-based track indices (omit = all tracks).
|
|
1397
|
+
local scope = parse_track_indices(params.trackIndices)
|
|
1398
|
+
|
|
1399
|
+
-- fullChunk: capture exact track state chunks (faithful plugin internal state,
|
|
1400
|
+
-- e.g. Helix Native snapshots) instead of parameter values. Defaults to true
|
|
1401
|
+
-- when scoped (the user wants an exact A/B of specific tracks), false otherwise
|
|
1402
|
+
-- (whole-project chunk snapshots would be very large).
|
|
1403
|
+
local full_chunk = params.fullChunk
|
|
1404
|
+
if full_chunk == nil then full_chunk = (scope ~= nil) end
|
|
1405
|
+
|
|
1406
|
+
local state = { version = 2, tracks = {}, fullChunk = full_chunk and true or false }
|
|
1149
1407
|
local count = reaper.CountTracks(0)
|
|
1150
1408
|
|
|
1151
|
-
|
|
1409
|
+
-- Build the ordered list of track indices to capture.
|
|
1410
|
+
local indices = {}
|
|
1411
|
+
if scope then
|
|
1412
|
+
state.scoped = true
|
|
1413
|
+
for _, idx in ipairs(scope) do
|
|
1414
|
+
if idx >= 0 and idx < count then indices[#indices + 1] = idx end
|
|
1415
|
+
end
|
|
1416
|
+
else
|
|
1417
|
+
for i = 0, count - 1 do indices[#indices + 1] = i end
|
|
1418
|
+
end
|
|
1419
|
+
|
|
1420
|
+
for _, i in ipairs(indices) do
|
|
1152
1421
|
local track = reaper.GetTrack(0, i)
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1422
|
+
if track then
|
|
1423
|
+
local _, name = reaper.GetTrackName(track)
|
|
1424
|
+
local track_entry = {
|
|
1425
|
+
index = i,
|
|
1426
|
+
name = name,
|
|
1427
|
+
color = reaper.GetMediaTrackInfo_Value(track, "I_CUSTOMCOLOR"),
|
|
1428
|
+
volume = reaper.GetMediaTrackInfo_Value(track, "D_VOL"),
|
|
1429
|
+
pan = reaper.GetMediaTrackInfo_Value(track, "D_PAN"),
|
|
1430
|
+
mute = reaper.GetMediaTrackInfo_Value(track, "B_MUTE") ~= 0,
|
|
1431
|
+
solo = reaper.GetMediaTrackInfo_Value(track, "I_SOLO") ~= 0,
|
|
1432
|
+
}
|
|
1159
1433
|
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
local
|
|
1172
|
-
local
|
|
1173
|
-
for
|
|
1174
|
-
local
|
|
1175
|
-
|
|
1434
|
+
if full_chunk then
|
|
1435
|
+
-- Exact, complete track state: FX chain + each plugin's internal VST
|
|
1436
|
+
-- state (incl. Helix snapshot selection), routing, envelopes. Base64 so
|
|
1437
|
+
-- the chunk's quotes/newlines/{GUID} braces survive the JSON round-trip.
|
|
1438
|
+
local ok, chunk = reaper.GetTrackStateChunk(track, "", false)
|
|
1439
|
+
if ok and chunk and chunk ~= "" then
|
|
1440
|
+
track_entry.chunkB64 = base64_encode(chunk)
|
|
1441
|
+
end
|
|
1442
|
+
else
|
|
1443
|
+
-- Lightweight: FX bypass/offline + parameter values + send levels (the
|
|
1444
|
+
-- original, backward-compatible capture).
|
|
1445
|
+
local fx_count = reaper.TrackFX_GetCount(track)
|
|
1446
|
+
local fx_states = {}
|
|
1447
|
+
for j = 0, fx_count - 1 do
|
|
1448
|
+
local _, preset = reaper.TrackFX_GetPreset(track, j)
|
|
1449
|
+
local _, fx_name = reaper.TrackFX_GetFXName(track, j)
|
|
1450
|
+
local params_data = {}
|
|
1451
|
+
if include_fx_params then
|
|
1452
|
+
local param_count = reaper.TrackFX_GetNumParams(track, j)
|
|
1453
|
+
local limit = math.min(param_count, max_params)
|
|
1454
|
+
for p = 0, limit - 1 do
|
|
1455
|
+
params_data[p + 1] = reaper.TrackFX_GetParam(track, j, p)
|
|
1456
|
+
end
|
|
1457
|
+
end
|
|
1458
|
+
fx_states[j + 1] = {
|
|
1459
|
+
name = fx_name,
|
|
1460
|
+
enabled = reaper.TrackFX_GetEnabled(track, j),
|
|
1461
|
+
offline = reaper.TrackFX_GetOffline(track, j),
|
|
1462
|
+
preset = preset or "",
|
|
1463
|
+
params = params_data,
|
|
1464
|
+
}
|
|
1176
1465
|
end
|
|
1177
|
-
end
|
|
1178
1466
|
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1467
|
+
local send_count = reaper.GetTrackNumSends(track, 0)
|
|
1468
|
+
local sends = {}
|
|
1469
|
+
for s = 0, send_count - 1 do
|
|
1470
|
+
local dest_track = reaper.GetTrackSendInfo_Value(track, 0, s, "P_DESTTRACK")
|
|
1471
|
+
local dest_idx = -1
|
|
1472
|
+
local dest_name = ""
|
|
1473
|
+
if dest_track then
|
|
1474
|
+
dest_idx = reaper.GetMediaTrackInfo_Value(dest_track, "IP_TRACKNUMBER") - 1
|
|
1475
|
+
local _, dname = reaper.GetTrackName(dest_track)
|
|
1476
|
+
dest_name = dname or ""
|
|
1477
|
+
end
|
|
1478
|
+
sends[s + 1] = {
|
|
1479
|
+
destTrackIndex = dest_idx,
|
|
1480
|
+
destTrackName = dest_name,
|
|
1481
|
+
volume = reaper.GetTrackSendInfo_Value(track, 0, s, "D_VOL"),
|
|
1482
|
+
pan = reaper.GetTrackSendInfo_Value(track, 0, s, "D_PAN"),
|
|
1483
|
+
muted = reaper.GetTrackSendInfo_Value(track, 0, s, "B_MUTE") ~= 0,
|
|
1484
|
+
}
|
|
1485
|
+
end
|
|
1187
1486
|
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
if dest_track then
|
|
1196
|
-
dest_idx = reaper.GetMediaTrackInfo_Value(dest_track, "IP_TRACKNUMBER") - 1
|
|
1197
|
-
local _, dname = reaper.GetTrackName(dest_track)
|
|
1198
|
-
dest_name = dname or ""
|
|
1487
|
+
track_entry.fx = fx_states
|
|
1488
|
+
track_entry.sends = sends
|
|
1489
|
+
track_entry.fxEnabled = (function()
|
|
1490
|
+
local arr = {}
|
|
1491
|
+
for j = 1, #fx_states do arr[j] = fx_states[j].enabled end
|
|
1492
|
+
return arr
|
|
1493
|
+
end)()
|
|
1199
1494
|
end
|
|
1200
|
-
sends[s + 1] = {
|
|
1201
|
-
destTrackIndex = dest_idx,
|
|
1202
|
-
destTrackName = dest_name,
|
|
1203
|
-
volume = reaper.GetTrackSendInfo_Value(track, 0, s, "D_VOL"),
|
|
1204
|
-
pan = reaper.GetTrackSendInfo_Value(track, 0, s, "D_PAN"),
|
|
1205
|
-
muted = reaper.GetTrackSendInfo_Value(track, 0, s, "B_MUTE") ~= 0,
|
|
1206
|
-
}
|
|
1207
|
-
end
|
|
1208
1495
|
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
name = name,
|
|
1212
|
-
color = color,
|
|
1213
|
-
volume = vol,
|
|
1214
|
-
pan = pan,
|
|
1215
|
-
mute = mute ~= 0,
|
|
1216
|
-
solo = solo ~= 0,
|
|
1217
|
-
fx = fx_states,
|
|
1218
|
-
sends = sends,
|
|
1219
|
-
-- Legacy compat: flat fxEnabled array (derived from fx_states)
|
|
1220
|
-
fxEnabled = (function()
|
|
1221
|
-
local arr = {}
|
|
1222
|
-
for j = 1, #fx_states do
|
|
1223
|
-
arr[j] = fx_states[j].enabled
|
|
1224
|
-
end
|
|
1225
|
-
return arr
|
|
1226
|
-
end)(),
|
|
1227
|
-
}
|
|
1496
|
+
state.tracks[#state.tracks + 1] = track_entry
|
|
1497
|
+
end
|
|
1228
1498
|
end
|
|
1229
1499
|
return state
|
|
1230
1500
|
end
|
|
@@ -1238,23 +1508,40 @@ function handlers.snapshot_save(params)
|
|
|
1238
1508
|
ensure_snapshot_dir()
|
|
1239
1509
|
|
|
1240
1510
|
local timestamp = os.time() * 1000
|
|
1241
|
-
local
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1511
|
+
local mixer_state = capture_mixer_state({
|
|
1512
|
+
includeFxParams = params.includeFxParams ~= false,
|
|
1513
|
+
maxParamsPerFx = params.maxParamsPerFx or 500,
|
|
1514
|
+
trackIndices = params.trackIndices,
|
|
1515
|
+
fullChunk = params.fullChunk,
|
|
1516
|
+
})
|
|
1517
|
+
-- Metadata first, mixerState last: a lister can read name/description/trackCount from the
|
|
1518
|
+
-- head of the file (mcp_snapshot_lib.lua's read_snapshot_meta reads only that prefix)
|
|
1519
|
+
local content = "{" .. table.concat({
|
|
1520
|
+
'"name":' .. json_encode(name),
|
|
1521
|
+
'"description":' .. json_encode(params.description or ""),
|
|
1522
|
+
'"timestamp":' .. json_encode(timestamp),
|
|
1523
|
+
'"trackCount":' .. #mixer_state.tracks,
|
|
1524
|
+
'"scoped":' .. tostring(mixer_state.scoped == true),
|
|
1525
|
+
'"fullChunk":' .. tostring(mixer_state.fullChunk == true),
|
|
1526
|
+
'"mixerState":' .. json_encode(mixer_state),
|
|
1527
|
+
}, ",") .. "}"
|
|
1250
1528
|
|
|
1251
1529
|
local path = snapshot_path(name)
|
|
1252
|
-
local ok = write_file(path,
|
|
1530
|
+
local ok = write_file(path, content)
|
|
1253
1531
|
if not ok then
|
|
1254
1532
|
return nil, "Failed to write snapshot file: " .. path
|
|
1255
1533
|
end
|
|
1256
1534
|
|
|
1257
|
-
return {
|
|
1535
|
+
return {
|
|
1536
|
+
success = true,
|
|
1537
|
+
name = name,
|
|
1538
|
+
timestamp = timestamp,
|
|
1539
|
+
path = path,
|
|
1540
|
+
storageLocation = get_snapshot_storage_location(),
|
|
1541
|
+
scoped = mixer_state.scoped == true,
|
|
1542
|
+
fullChunk = mixer_state.fullChunk == true,
|
|
1543
|
+
tracksCaptured = #mixer_state.tracks,
|
|
1544
|
+
}
|
|
1258
1545
|
end
|
|
1259
1546
|
|
|
1260
1547
|
function handlers.snapshot_restore(params)
|
|
@@ -1305,6 +1592,18 @@ function handlers.snapshot_restore(params)
|
|
|
1305
1592
|
|
|
1306
1593
|
local track = reaper.GetTrack(0, track_state.index)
|
|
1307
1594
|
if track then
|
|
1595
|
+
if track_state.chunkB64 then
|
|
1596
|
+
-- Exact restore: replace the entire track state from the saved chunk.
|
|
1597
|
+
-- This faithfully reproduces each plugin's internal state (incl. Helix
|
|
1598
|
+
-- Native snapshot selection) which parameter-value restore cannot. The
|
|
1599
|
+
-- chunk is this track's own state only, so children (e.g. guitars under
|
|
1600
|
+
-- a folder) are untouched.
|
|
1601
|
+
if reaper.SetTrackStateChunk(track, base64_decode(track_state.chunkB64), false) then
|
|
1602
|
+
restored = restored + 1
|
|
1603
|
+
else
|
|
1604
|
+
warnings[#warnings + 1] = "Track " .. track_state.index .. ": SetTrackStateChunk failed"
|
|
1605
|
+
end
|
|
1606
|
+
else
|
|
1308
1607
|
-- Basic mixer state (all versions)
|
|
1309
1608
|
reaper.SetMediaTrackInfo_Value(track, "D_VOL", track_state.volume)
|
|
1310
1609
|
reaper.SetMediaTrackInfo_Value(track, "D_PAN", track_state.pan)
|
|
@@ -1375,6 +1674,7 @@ function handlers.snapshot_restore(params)
|
|
|
1375
1674
|
end
|
|
1376
1675
|
|
|
1377
1676
|
restored = restored + 1
|
|
1677
|
+
end
|
|
1378
1678
|
end
|
|
1379
1679
|
end
|
|
1380
1680
|
end
|
|
@@ -1446,6 +1746,80 @@ function handlers.snapshot_delete(params)
|
|
|
1446
1746
|
return { name = name, deleted = true }
|
|
1447
1747
|
end
|
|
1448
1748
|
|
|
1749
|
+
-- Rename a snapshot and/or change its description; the mixer state is kept. A new name that saves to
|
|
1750
|
+
-- another snapshot's file is refused (pass the same name to change only the description).
|
|
1751
|
+
function handlers.snapshot_edit(params)
|
|
1752
|
+
local name = params.name
|
|
1753
|
+
if not name or name == "" then
|
|
1754
|
+
return nil, "name required"
|
|
1755
|
+
end
|
|
1756
|
+
local new_name = params.newName
|
|
1757
|
+
if new_name ~= nil then
|
|
1758
|
+
new_name = tostring(new_name):match("^%s*(.-)%s*$")
|
|
1759
|
+
if new_name == "" then return nil, "newName must not be empty" end
|
|
1760
|
+
else
|
|
1761
|
+
new_name = name
|
|
1762
|
+
end
|
|
1763
|
+
|
|
1764
|
+
local path = snapshot_path(name)
|
|
1765
|
+
local content = read_file(path)
|
|
1766
|
+
if not content then
|
|
1767
|
+
return nil, "Snapshot not found: " .. name
|
|
1768
|
+
end
|
|
1769
|
+
-- The mixer state is copied as written: the bridge's fallback decoder is flat, so it is never re-encoded.
|
|
1770
|
+
-- A JSON string can't hold a bare `"mixerState":` (its quotes would be escaped), so the first one is the key.
|
|
1771
|
+
local snapshot = json_decode(content)
|
|
1772
|
+
local key_end = select(2, content:find('"mixerState"%s*:%s*'))
|
|
1773
|
+
local raw_state
|
|
1774
|
+
if key_end and content:sub(key_end + 1, key_end + 1) == "{" then
|
|
1775
|
+
local depth, in_str, i = 0, false, key_end + 1
|
|
1776
|
+
while i <= #content do
|
|
1777
|
+
local c = content:sub(i, i)
|
|
1778
|
+
if in_str then
|
|
1779
|
+
if c == "\\" then i = i + 1 elseif c == '"' then in_str = false end
|
|
1780
|
+
elseif c == '"' then in_str = true
|
|
1781
|
+
elseif c == "{" or c == "[" then depth = depth + 1
|
|
1782
|
+
elseif c == "}" or c == "]" then
|
|
1783
|
+
depth = depth - 1
|
|
1784
|
+
if depth == 0 then raw_state = content:sub(key_end + 1, i); break end
|
|
1785
|
+
end
|
|
1786
|
+
i = i + 1
|
|
1787
|
+
end
|
|
1788
|
+
end
|
|
1789
|
+
if not snapshot or not raw_state then
|
|
1790
|
+
return nil, "Invalid snapshot file for: " .. name
|
|
1791
|
+
end
|
|
1792
|
+
local new_path = snapshot_path(new_name)
|
|
1793
|
+
-- A case-only rename is the same file on macOS and Windows: never remove it after writing
|
|
1794
|
+
local same_file = new_path:lower() == path:lower()
|
|
1795
|
+
if not same_file and read_file(new_path) then
|
|
1796
|
+
return nil, "A snapshot already saves to that name's file: " .. new_name
|
|
1797
|
+
end
|
|
1798
|
+
|
|
1799
|
+
local description = params.description
|
|
1800
|
+
if description == nil then description = snapshot.description or "" end
|
|
1801
|
+
local ok, state = pcall(plan_lib_ok and plan_lib.json_decode or json_decode, raw_state)
|
|
1802
|
+
if not ok or type(state) ~= "table" then state = {} end
|
|
1803
|
+
local edited = "{" .. table.concat({
|
|
1804
|
+
'"name":' .. json_encode(new_name),
|
|
1805
|
+
'"description":' .. json_encode(tostring(description)),
|
|
1806
|
+
'"timestamp":' .. json_encode(snapshot.timestamp or os.time() * 1000),
|
|
1807
|
+
'"trackCount":' .. (type(state.tracks) == "table" and #state.tracks or tonumber(snapshot.trackCount) or 0),
|
|
1808
|
+
'"scoped":' .. tostring(state.scoped == true or snapshot.scoped == true),
|
|
1809
|
+
'"fullChunk":' .. tostring(state.fullChunk == true or snapshot.fullChunk == true),
|
|
1810
|
+
'"mixerState":' .. raw_state,
|
|
1811
|
+
}, ",") .. "}"
|
|
1812
|
+
if not write_file(new_path, edited) then
|
|
1813
|
+
return nil, "Failed to write snapshot file: " .. new_path
|
|
1814
|
+
end
|
|
1815
|
+
if not same_file then os.remove(path) end
|
|
1816
|
+
-- The next/prev actions continue from the current snapshot by name
|
|
1817
|
+
if new_name ~= name and reaper.GetExtState("MCP_Snapshots", "current_name") == name then
|
|
1818
|
+
reaper.SetExtState("MCP_Snapshots", "current_name", new_name, false)
|
|
1819
|
+
end
|
|
1820
|
+
return { name = new_name, previousName = name, description = tostring(description), path = new_path }
|
|
1821
|
+
end
|
|
1822
|
+
|
|
1449
1823
|
-- =============================================================================
|
|
1450
1824
|
-- Routing handler
|
|
1451
1825
|
-- =============================================================================
|
|
@@ -1611,40 +1985,104 @@ local function find_or_create_mcp_container(track)
|
|
|
1611
1985
|
return idx
|
|
1612
1986
|
end
|
|
1613
1987
|
|
|
1988
|
+
-- remove_mcp_meters: delete the "MCP Meters" container and any loose MCP meter
|
|
1989
|
+
-- JSFX that the metering reads inserted. Leaves the MIDI emitter alone.
|
|
1990
|
+
-- params.trackIndices: list of track indices (-1 = master)
|
|
1991
|
+
function handlers.remove_mcp_meters(params)
|
|
1992
|
+
local indices = params.trackIndices
|
|
1993
|
+
-- Without CF_Json_Parse the command parser hands arrays over as a JSON string
|
|
1994
|
+
if type(indices) == "string" then
|
|
1995
|
+
local list = {}
|
|
1996
|
+
for n in indices:gmatch("%-?%d+") do list[#list + 1] = tonumber(n) end
|
|
1997
|
+
indices = list
|
|
1998
|
+
end
|
|
1999
|
+
if type(indices) ~= "table" or #indices == 0 then
|
|
2000
|
+
return nil, "trackIndices required"
|
|
2001
|
+
end
|
|
2002
|
+
local removed = {}
|
|
2003
|
+
reaper.Undo_BeginBlock()
|
|
2004
|
+
for _, idx in ipairs(indices) do
|
|
2005
|
+
local track = reaper.GetTrack(0, idx)
|
|
2006
|
+
local count = 0
|
|
2007
|
+
if track then
|
|
2008
|
+
for i = reaper.TrackFX_GetCount(track) - 1, 0, -1 do
|
|
2009
|
+
local _, name = reaper.TrackFX_GetFXName(track, i)
|
|
2010
|
+
local is_meter = name and (name == "MCP Meters"
|
|
2011
|
+
or (name:find(MCP_FX_PREFIX, 1, true) and not name:find("MIDI Emitter", 1, true)))
|
|
2012
|
+
if is_meter then
|
|
2013
|
+
reaper.TrackFX_Delete(track, i)
|
|
2014
|
+
count = count + 1
|
|
2015
|
+
end
|
|
2016
|
+
end
|
|
2017
|
+
container_cache[tostring(track)] = nil
|
|
2018
|
+
end
|
|
2019
|
+
removed[#removed + 1] = { trackIndex = idx, removed = count, found = track ~= nil }
|
|
2020
|
+
end
|
|
2021
|
+
reaper.Undo_EndBlock("MCP: Remove meters", -1)
|
|
2022
|
+
return { tracks = removed }
|
|
2023
|
+
end
|
|
2024
|
+
|
|
1614
2025
|
-- Helper: find or auto-insert a named JSFX on a track.
|
|
1615
2026
|
-- Tries to place inside an "MCP Meters" FX Container (REAPER 7.06+).
|
|
1616
2027
|
-- Falls back to direct insertion on older REAPER versions.
|
|
1617
2028
|
-- Returns the FX index (possibly container-addressed) on success, or nil + error.
|
|
1618
|
-
|
|
2029
|
+
function ensure_jsfx_on_track(track, fx_name)
|
|
2030
|
+
-- REAPER names a JSFX "JS: <desc> [<path>]" but leaves the path out for some
|
|
2031
|
+
-- (seen live: the MIDI emitter is just "JS: MCP MIDI Emitter"), so match either.
|
|
2032
|
+
local desc = ({
|
|
2033
|
+
["reaper-mcp/mcp_analyzer"] = "JS: MCP Spectrum Analyzer",
|
|
2034
|
+
["reaper-mcp/mcp_lufs_meter"] = "JS: MCP LUFS Meter",
|
|
2035
|
+
["reaper-mcp/mcp_correlation_meter"] = "JS: MCP Correlation Meter",
|
|
2036
|
+
["reaper-mcp/mcp_crest_factor"] = "JS: MCP Crest Factor Meter",
|
|
2037
|
+
["reaper-mcp/mcp_midi_emitter"] = "JS: MCP MIDI Emitter",
|
|
2038
|
+
})[fx_name]
|
|
2039
|
+
local function is_it(name)
|
|
2040
|
+
return name and (name:find(fx_name, 1, true) or (desc and name:find(desc, 1, true) == 1))
|
|
2041
|
+
end
|
|
2042
|
+
|
|
1619
2043
|
-- Try container approach first (REAPER 7.06+)
|
|
1620
2044
|
local container_idx = find_or_create_mcp_container(track)
|
|
1621
2045
|
if container_idx then
|
|
2046
|
+
-- Seen live (REAPER 7.40): a container added as a track's only FX comes up
|
|
2047
|
+
-- bypassed, so the meters inside never see audio and read zeros. It only
|
|
2048
|
+
-- holds pass-through meters, so it is safe to keep it enabled.
|
|
2049
|
+
if not reaper.TrackFX_GetEnabled(track, container_idx) then
|
|
2050
|
+
reaper.TrackFX_SetEnabled(track, container_idx, true)
|
|
2051
|
+
end
|
|
1622
2052
|
local ok, count_str = reaper.TrackFX_GetNamedConfigParm(track, container_idx, "container_count")
|
|
1623
2053
|
local count = tonumber(count_str) or 0
|
|
1624
2054
|
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
return addr
|
|
2055
|
+
local function find_in_container()
|
|
2056
|
+
local _, n_str = reaper.TrackFX_GetNamedConfigParm(track, container_idx, "container_count")
|
|
2057
|
+
for i = 0, (tonumber(n_str) or 0) - 1 do
|
|
2058
|
+
local _, addr_str = reaper.TrackFX_GetNamedConfigParm(track, container_idx, "container_item." .. i)
|
|
2059
|
+
local addr = tonumber(addr_str)
|
|
2060
|
+
if addr then
|
|
2061
|
+
local _, name = reaper.TrackFX_GetFXName(track, addr)
|
|
2062
|
+
if is_it(name) then return addr end
|
|
1633
2063
|
end
|
|
1634
2064
|
end
|
|
1635
2065
|
end
|
|
1636
2066
|
|
|
2067
|
+
local found = find_in_container()
|
|
2068
|
+
if found then return found end
|
|
2069
|
+
|
|
1637
2070
|
-- Not found — insert inside container
|
|
1638
2071
|
-- Use legacy addressing: 0x2000000 + (1-based slot) * (top_chain_count + 1) + (1-based container pos)
|
|
1639
2072
|
local top_count = reaper.TrackFX_GetCount(track)
|
|
1640
2073
|
local slot = count + 1 -- 1-based, next empty slot
|
|
1641
2074
|
local container_pos = container_idx + 1 -- 1-based
|
|
1642
2075
|
local insert_addr = 0x2000000 + slot * (top_count + 1) + container_pos
|
|
1643
|
-
|
|
1644
|
-
if new_idx >= 0 then
|
|
2076
|
+
if reaper.TrackFX_AddByName(track, fx_name, false, insert_addr) >= 0 then
|
|
1645
2077
|
-- Invalidate cache since FX count changed
|
|
1646
2078
|
container_cache[tostring(track)] = nil
|
|
1647
|
-
|
|
2079
|
+
-- Look the new meter up by name: the index TrackFX_AddByName returns for a
|
|
2080
|
+
-- container insert is not its address. Callers set slider params on the
|
|
2081
|
+
-- returned index, and the wrong index wrote them into the track's first FX
|
|
2082
|
+
-- (seen live: Pro-Q 4 band 1 gain pushed to +30 dB).
|
|
2083
|
+
found = find_in_container()
|
|
2084
|
+
if found then return found end
|
|
2085
|
+
return nil, "Internal error: " .. fx_name .. " was inserted but could not be found in the MCP Meters container"
|
|
1648
2086
|
end
|
|
1649
2087
|
-- Container insertion failed — fall through to direct
|
|
1650
2088
|
end
|
|
@@ -1653,7 +2091,7 @@ local function ensure_jsfx_on_track(track, fx_name)
|
|
|
1653
2091
|
local fx_count = reaper.TrackFX_GetCount(track)
|
|
1654
2092
|
for i = 0, fx_count - 1 do
|
|
1655
2093
|
local _, name = reaper.TrackFX_GetFXName(track, i)
|
|
1656
|
-
if name
|
|
2094
|
+
if is_it(name) then
|
|
1657
2095
|
return i
|
|
1658
2096
|
end
|
|
1659
2097
|
end
|
|
@@ -1674,17 +2112,21 @@ function handlers.read_track_lufs(params)
|
|
|
1674
2112
|
local fx_idx, err = ensure_jsfx_on_track(track, MCP_LUFS_METER_FX_NAME)
|
|
1675
2113
|
if not fx_idx then return nil, err end
|
|
1676
2114
|
|
|
1677
|
-
--
|
|
1678
|
-
|
|
2115
|
+
-- Give this instance its own gmem slot (master -1 -> 0). TrackFX_SetParam
|
|
2116
|
+
-- takes the RAW slider value; changing it resets the measurement.
|
|
2117
|
+
local slot = idx + 1
|
|
1679
2118
|
local current_slot = reaper.TrackFX_GetParam(track, fx_idx, 1)
|
|
1680
|
-
if math.abs(current_slot -
|
|
1681
|
-
reaper.TrackFX_SetParam(track, fx_idx, 1,
|
|
2119
|
+
if math.abs(current_slot - slot) > 0.5 then
|
|
2120
|
+
reaper.TrackFX_SetParam(track, fx_idx, 1, slot)
|
|
2121
|
+
end
|
|
2122
|
+
if params.reset then
|
|
2123
|
+
reaper.TrackFX_SetParam(track, fx_idx, 0, 1) -- the Reset slider; the JSFX sets it back to 0
|
|
1682
2124
|
end
|
|
1683
2125
|
|
|
1684
2126
|
-- Read from gmem (JSFX writes here from @sample)
|
|
1685
2127
|
reaper.gmem_attach("MCPLufsMeter")
|
|
1686
2128
|
|
|
1687
|
-
local base =
|
|
2129
|
+
local base = slot * 8
|
|
1688
2130
|
local integrated = reaper.gmem_read(base + 0)
|
|
1689
2131
|
local short_term = reaper.gmem_read(base + 1)
|
|
1690
2132
|
local momentary = reaper.gmem_read(base + 2)
|
|
@@ -1828,6 +2270,25 @@ local function ppq_to_beats(take, item)
|
|
|
1828
2270
|
end
|
|
1829
2271
|
end
|
|
1830
2272
|
|
|
2273
|
+
-- Helper: count a take's channel events by type. MIDI_CountEvts' "cc" count is
|
|
2274
|
+
-- every channel event MIDI_GetCC enumerates (CC, pitch bend, program change,
|
|
2275
|
+
-- channel/poly pressure), so ccCount comes from here: CC messages (0xB0) only.
|
|
2276
|
+
local CHANNEL_EVENT_KINDS = {
|
|
2277
|
+
[176] = "cc", [224] = "pitchBend", [192] = "programChange",
|
|
2278
|
+
[208] = "channelPressure", [160] = "polyAftertouch",
|
|
2279
|
+
}
|
|
2280
|
+
|
|
2281
|
+
local function count_channel_events(take)
|
|
2282
|
+
local _, _, evt_cnt, _ = reaper.MIDI_CountEvts(take)
|
|
2283
|
+
local counts = { cc = 0, pitchBend = 0, programChange = 0, channelPressure = 0, polyAftertouch = 0, other = 0 }
|
|
2284
|
+
for i = 0, evt_cnt - 1 do
|
|
2285
|
+
local _, _, _, _, chanmsg = reaper.MIDI_GetCC(take, i)
|
|
2286
|
+
local kind = CHANNEL_EVENT_KINDS[chanmsg] or "other"
|
|
2287
|
+
counts[kind] = counts[kind] + 1
|
|
2288
|
+
end
|
|
2289
|
+
return counts.cc, evt_cnt, counts
|
|
2290
|
+
end
|
|
2291
|
+
|
|
1831
2292
|
function handlers.create_midi_item(params)
|
|
1832
2293
|
local track_idx = params.trackIndex
|
|
1833
2294
|
local start_pos = params.startPosition
|
|
@@ -1878,13 +2339,16 @@ function handlers.list_midi_items(params)
|
|
|
1878
2339
|
local item = reaper.GetTrackMediaItem(track, i)
|
|
1879
2340
|
local take = reaper.GetActiveTake(item)
|
|
1880
2341
|
if take and reaper.TakeIsMIDI(take) then
|
|
1881
|
-
local _, note_cnt,
|
|
2342
|
+
local _, note_cnt, _, _ = reaper.MIDI_CountEvts(take)
|
|
2343
|
+
local cc_cnt, evt_cnt, evt_counts = count_channel_events(take)
|
|
1882
2344
|
items[#items + 1] = {
|
|
1883
2345
|
itemIndex = i,
|
|
1884
2346
|
position = reaper.GetMediaItemInfo_Value(item, "D_POSITION"),
|
|
1885
2347
|
length = reaper.GetMediaItemInfo_Value(item, "D_LENGTH"),
|
|
1886
2348
|
noteCount = note_cnt,
|
|
1887
2349
|
ccCount = cc_cnt,
|
|
2350
|
+
channelEventCount = evt_cnt,
|
|
2351
|
+
channelEventCounts = evt_counts,
|
|
1888
2352
|
muted = reaper.GetMediaItemInfo_Value(item, "B_MUTE") ~= 0,
|
|
1889
2353
|
}
|
|
1890
2354
|
end
|
|
@@ -1940,7 +2404,8 @@ function handlers.analyze_midi(params)
|
|
|
1940
2404
|
local track, item, take, err = get_midi_take(params)
|
|
1941
2405
|
if err then return nil, err end
|
|
1942
2406
|
|
|
1943
|
-
local _, note_cnt,
|
|
2407
|
+
local _, note_cnt, _, _ = reaper.MIDI_CountEvts(take)
|
|
2408
|
+
local cc_cnt, evt_cnt, evt_counts = count_channel_events(take)
|
|
1944
2409
|
local to_beats = ppq_to_beats(take, item)
|
|
1945
2410
|
|
|
1946
2411
|
-- Per-pitch accumulators
|
|
@@ -2028,6 +2493,8 @@ function handlers.analyze_midi(params)
|
|
|
2028
2493
|
itemIndex = params.itemIndex,
|
|
2029
2494
|
totalNotes = note_cnt,
|
|
2030
2495
|
totalCC = cc_cnt,
|
|
2496
|
+
channelEventCount = evt_cnt,
|
|
2497
|
+
channelEventCounts = evt_counts,
|
|
2031
2498
|
durationBeats = math.floor(max_end_beat * 100 + 0.5) / 100,
|
|
2032
2499
|
pitchStats = pitch_stats,
|
|
2033
2500
|
velocityHistogram = vel_histogram,
|
|
@@ -2060,6 +2527,7 @@ function handlers.insert_midi_note(params)
|
|
|
2060
2527
|
reaper.Undo_BeginBlock()
|
|
2061
2528
|
reaper.MIDI_InsertNote(take, false, false, start_ppq, end_ppq, chan, pitch, vel, false)
|
|
2062
2529
|
reaper.MIDI_Sort(take)
|
|
2530
|
+
reaper.MarkTrackItemsDirty(track, item) -- else REAPER merges this into the previous MIDI undo point
|
|
2063
2531
|
reaper.Undo_EndBlock("MCP: Insert MIDI note", -1)
|
|
2064
2532
|
|
|
2065
2533
|
reaper.UpdateArrange()
|
|
@@ -2103,6 +2571,7 @@ function handlers.insert_midi_notes(params)
|
|
|
2103
2571
|
end
|
|
2104
2572
|
end
|
|
2105
2573
|
reaper.MIDI_Sort(take)
|
|
2574
|
+
reaper.MarkTrackItemsDirty(track, item) -- else REAPER merges this into the previous MIDI undo point
|
|
2106
2575
|
reaper.Undo_EndBlock("MCP: Insert " .. inserted .. " MIDI notes", -1)
|
|
2107
2576
|
|
|
2108
2577
|
reaper.UpdateArrange()
|
|
@@ -2152,6 +2621,7 @@ function handlers.edit_midi_note(params)
|
|
|
2152
2621
|
reaper.Undo_BeginBlock()
|
|
2153
2622
|
reaper.MIDI_SetNote(take, note_idx, sel, muted, new_start_ppq, new_end_ppq, new_chan, new_pitch, new_vel, false)
|
|
2154
2623
|
reaper.MIDI_Sort(take)
|
|
2624
|
+
reaper.MarkTrackItemsDirty(track, item) -- else REAPER merges this into the previous MIDI undo point
|
|
2155
2625
|
reaper.Undo_EndBlock("MCP: Edit MIDI note " .. note_idx, -1)
|
|
2156
2626
|
|
|
2157
2627
|
reaper.UpdateArrange()
|
|
@@ -2210,6 +2680,7 @@ function handlers.edit_midi_notes(params)
|
|
|
2210
2680
|
end
|
|
2211
2681
|
|
|
2212
2682
|
reaper.MIDI_Sort(take)
|
|
2683
|
+
reaper.MarkTrackItemsDirty(track, item) -- else REAPER merges this into the previous MIDI undo point
|
|
2213
2684
|
reaper.Undo_EndBlock("MCP: Batch edit " .. edited .. " MIDI notes", -1)
|
|
2214
2685
|
reaper.UpdateArrange()
|
|
2215
2686
|
|
|
@@ -2232,6 +2703,7 @@ function handlers.delete_midi_note(params)
|
|
|
2232
2703
|
|
|
2233
2704
|
reaper.Undo_BeginBlock()
|
|
2234
2705
|
reaper.MIDI_DeleteNote(take, note_idx)
|
|
2706
|
+
reaper.MarkTrackItemsDirty(track, item) -- else REAPER merges this into the previous MIDI undo point
|
|
2235
2707
|
reaper.Undo_EndBlock("MCP: Delete MIDI note " .. note_idx, -1)
|
|
2236
2708
|
|
|
2237
2709
|
reaper.UpdateArrange()
|
|
@@ -2291,12 +2763,13 @@ function handlers.insert_midi_cc(params)
|
|
|
2291
2763
|
-- chanmsg 176 = 0xB0 = CC message
|
|
2292
2764
|
reaper.MIDI_InsertCC(take, false, false, ppq, 176, chan, cc_num, value)
|
|
2293
2765
|
reaper.MIDI_Sort(take)
|
|
2766
|
+
reaper.MarkTrackItemsDirty(track, item) -- else REAPER merges this into the previous MIDI undo point
|
|
2294
2767
|
reaper.Undo_EndBlock("MCP: Insert MIDI CC " .. cc_num, -1)
|
|
2295
2768
|
|
|
2296
2769
|
reaper.UpdateArrange()
|
|
2297
2770
|
|
|
2298
|
-
local
|
|
2299
|
-
return { success = true, ccCount = cc_cnt }
|
|
2771
|
+
local cc_cnt, evt_cnt = count_channel_events(take)
|
|
2772
|
+
return { success = true, ccCount = cc_cnt, channelEventCount = evt_cnt }
|
|
2300
2773
|
end
|
|
2301
2774
|
|
|
2302
2775
|
function handlers.delete_midi_cc(params)
|
|
@@ -2306,26 +2779,29 @@ function handlers.delete_midi_cc(params)
|
|
|
2306
2779
|
local cc_idx = params.ccIndex
|
|
2307
2780
|
if cc_idx == nil then return nil, "ccIndex required" end
|
|
2308
2781
|
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2782
|
+
-- ccIndex addresses every channel event (as get_midi_cc reports it), not CCs only
|
|
2783
|
+
local _, _, evt_cnt, _ = reaper.MIDI_CountEvts(take)
|
|
2784
|
+
if cc_idx >= evt_cnt then
|
|
2785
|
+
return nil, "CC index " .. cc_idx .. " out of range (item has " .. evt_cnt .. " channel events)"
|
|
2312
2786
|
end
|
|
2313
2787
|
|
|
2314
2788
|
reaper.Undo_BeginBlock()
|
|
2315
2789
|
reaper.MIDI_DeleteCC(take, cc_idx)
|
|
2790
|
+
reaper.MarkTrackItemsDirty(track, item) -- else REAPER merges this into the previous MIDI undo point
|
|
2316
2791
|
reaper.Undo_EndBlock("MCP: Delete MIDI CC " .. cc_idx, -1)
|
|
2317
2792
|
|
|
2318
2793
|
reaper.UpdateArrange()
|
|
2319
2794
|
|
|
2320
|
-
local
|
|
2321
|
-
return { success = true, ccCount =
|
|
2795
|
+
local cc_cnt, new_evt_cnt = count_channel_events(take)
|
|
2796
|
+
return { success = true, ccCount = cc_cnt, channelEventCount = new_evt_cnt }
|
|
2322
2797
|
end
|
|
2323
2798
|
|
|
2324
2799
|
function handlers.get_midi_item_properties(params)
|
|
2325
2800
|
local track, item, take, err = get_midi_take(params)
|
|
2326
2801
|
if err then return nil, err end
|
|
2327
2802
|
|
|
2328
|
-
local _, note_cnt,
|
|
2803
|
+
local _, note_cnt, _, _ = reaper.MIDI_CountEvts(take)
|
|
2804
|
+
local cc_cnt, evt_cnt, evt_counts = count_channel_events(take)
|
|
2329
2805
|
|
|
2330
2806
|
return {
|
|
2331
2807
|
trackIndex = params.trackIndex,
|
|
@@ -2334,6 +2810,8 @@ function handlers.get_midi_item_properties(params)
|
|
|
2334
2810
|
length = reaper.GetMediaItemInfo_Value(item, "D_LENGTH"),
|
|
2335
2811
|
noteCount = note_cnt,
|
|
2336
2812
|
ccCount = cc_cnt,
|
|
2813
|
+
channelEventCount = evt_cnt,
|
|
2814
|
+
channelEventCounts = evt_counts,
|
|
2337
2815
|
muted = reaper.GetMediaItemInfo_Value(item, "B_MUTE") ~= 0,
|
|
2338
2816
|
loopSource = reaper.GetMediaItemInfo_Value(item, "B_LOOPSRC") ~= 0,
|
|
2339
2817
|
}
|
|
@@ -2426,8 +2904,9 @@ function handlers.get_media_item_properties(params)
|
|
|
2426
2904
|
start_offset = reaper.GetMediaItemTakeInfo_Value(take, "D_STARTOFFS")
|
|
2427
2905
|
local source = reaper.GetMediaItemTake_Source(take)
|
|
2428
2906
|
if source then
|
|
2429
|
-
|
|
2430
|
-
|
|
2907
|
+
-- REAPER returns just the file name here (older docs show retval, name).
|
|
2908
|
+
local a, b = reaper.GetMediaSourceFileName(source, "")
|
|
2909
|
+
source_file = type(b) == "string" and b or (type(a) == "string" and a or "")
|
|
2431
2910
|
end
|
|
2432
2911
|
end
|
|
2433
2912
|
|
|
@@ -2799,12 +3278,7 @@ function handlers.set_multiple_track_properties(params)
|
|
|
2799
3278
|
local tracks_input = params.tracks
|
|
2800
3279
|
if not tracks_input then return nil, "tracks array required" end
|
|
2801
3280
|
|
|
2802
|
-
local tracks =
|
|
2803
|
-
if type(tracks_input) == "string" then
|
|
2804
|
-
tracks = json_decode(tracks_input)
|
|
2805
|
-
elseif type(tracks_input) == "table" then
|
|
2806
|
-
tracks = tracks_input
|
|
2807
|
-
end
|
|
3281
|
+
local tracks = json_decode_array(tracks_input)
|
|
2808
3282
|
if not tracks then return nil, "Failed to parse tracks array" end
|
|
2809
3283
|
|
|
2810
3284
|
local updated = 0
|
|
@@ -2861,12 +3335,7 @@ function handlers.setup_fx_chain(params)
|
|
|
2861
3335
|
local plugins_input = params.plugins
|
|
2862
3336
|
if not plugins_input then return nil, "plugins array required" end
|
|
2863
3337
|
|
|
2864
|
-
local plugins =
|
|
2865
|
-
if type(plugins_input) == "string" then
|
|
2866
|
-
plugins = json_decode(plugins_input)
|
|
2867
|
-
elseif type(plugins_input) == "table" then
|
|
2868
|
-
plugins = plugins_input
|
|
2869
|
-
end
|
|
3338
|
+
local plugins = json_decode_array(plugins_input)
|
|
2870
3339
|
if not plugins then return nil, "Failed to parse plugins array" end
|
|
2871
3340
|
|
|
2872
3341
|
local track = reaper.GetTrack(0, track_idx)
|
|
@@ -2894,8 +3363,10 @@ function handlers.setup_fx_chain(params)
|
|
|
2894
3363
|
|
|
2895
3364
|
-- Set initial parameters if provided
|
|
2896
3365
|
local param_errors = {}
|
|
2897
|
-
|
|
2898
|
-
|
|
3366
|
+
-- Nested arrays arrive as JSON text from the fallback decoder
|
|
3367
|
+
local plugin_params = plugin.parameters and json_decode_array(plugin.parameters)
|
|
3368
|
+
if plugin_params then
|
|
3369
|
+
for _, p in ipairs(plugin_params) do
|
|
2899
3370
|
if p.index ~= nil and p.value ~= nil then
|
|
2900
3371
|
local ok = reaper.TrackFX_SetParam(track, fx_idx, p.index, p.value)
|
|
2901
3372
|
if not ok then
|
|
@@ -2938,12 +3409,7 @@ function handlers.set_multiple_fx_parameters(params)
|
|
|
2938
3409
|
local updates_input = params.updates
|
|
2939
3410
|
if not updates_input then return nil, "updates array required" end
|
|
2940
3411
|
|
|
2941
|
-
local updates =
|
|
2942
|
-
if type(updates_input) == "string" then
|
|
2943
|
-
updates = json_decode(updates_input)
|
|
2944
|
-
elseif type(updates_input) == "table" then
|
|
2945
|
-
updates = updates_input
|
|
2946
|
-
end
|
|
3412
|
+
local updates = json_decode_array(updates_input)
|
|
2947
3413
|
if not updates then return nil, "Failed to parse updates array" end
|
|
2948
3414
|
|
|
2949
3415
|
local updated = 0
|
|
@@ -2982,131 +3448,635 @@ function handlers.set_multiple_fx_parameters(params)
|
|
|
2982
3448
|
end
|
|
2983
3449
|
|
|
2984
3450
|
-- =============================================================================
|
|
2985
|
-
--
|
|
3451
|
+
-- Plan executor — one bridge call, one undo block, all-or-nothing
|
|
2986
3452
|
-- =============================================================================
|
|
2987
3453
|
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
local
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
tracks[#tracks + 1] = { index = idx, name = name }
|
|
2996
|
-
end
|
|
2997
|
-
return { tracks = tracks, count = count }
|
|
3454
|
+
-- Custom: macros and scripts are only listed in reaper-kb.ini.
|
|
3455
|
+
local function read_kb_ini()
|
|
3456
|
+
local f = io.open(reaper.GetResourcePath() .. "/reaper-kb.ini", "r")
|
|
3457
|
+
if not f then return "" end
|
|
3458
|
+
local text = f:read("*a")
|
|
3459
|
+
f:close()
|
|
3460
|
+
return text
|
|
2998
3461
|
end
|
|
2999
3462
|
|
|
3000
|
-
|
|
3001
|
-
local start_pos, end_pos = reaper.GetSet_LoopTimeRange(false, false, 0, 0, false)
|
|
3002
|
-
return {
|
|
3003
|
-
start = start_pos,
|
|
3004
|
-
["end"] = end_pos,
|
|
3005
|
-
length = end_pos - start_pos,
|
|
3006
|
-
hasSelection = end_pos > start_pos,
|
|
3007
|
-
}
|
|
3008
|
-
end
|
|
3463
|
+
local plan_ctx = { applied = {} } -- idempotency keys of recently applied plans
|
|
3009
3464
|
|
|
3010
|
-
function handlers.
|
|
3011
|
-
if
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3465
|
+
function handlers.run_plan(params)
|
|
3466
|
+
if not plan_lib_ok then
|
|
3467
|
+
return nil, "mcp_plan_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(plan_lib) .. ")"
|
|
3468
|
+
end
|
|
3469
|
+
plan_ctx.snapshot_save = function(name) return handlers.snapshot_save({ name = name }) end
|
|
3470
|
+
plan_ctx.actions = actions_lib_ok and actions_lib or nil
|
|
3471
|
+
plan_ctx.kb_text = actions_lib_ok and read_kb_ini() or nil
|
|
3472
|
+
return plan_lib.run_plan(reaper, params, plan_ctx)
|
|
3017
3473
|
end
|
|
3018
3474
|
|
|
3019
3475
|
-- =============================================================================
|
|
3020
|
-
--
|
|
3476
|
+
-- Action proxy + selection setters (logic in mcp_actions_lib.lua)
|
|
3021
3477
|
-- =============================================================================
|
|
3022
3478
|
|
|
3023
|
-
function
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
local markers = {}
|
|
3027
|
-
for i = 0, total - 1 do
|
|
3028
|
-
local _, isrgn, pos, rgnend, name, markrgnindexnumber, color = reaper.EnumProjectMarkers3(0, i)
|
|
3029
|
-
if not isrgn then
|
|
3030
|
-
markers[#markers + 1] = {
|
|
3031
|
-
index = markrgnindexnumber,
|
|
3032
|
-
name = name,
|
|
3033
|
-
position = pos,
|
|
3034
|
-
color = color,
|
|
3035
|
-
}
|
|
3036
|
-
end
|
|
3479
|
+
local function with_actions_lib(fn)
|
|
3480
|
+
if not actions_lib_ok then
|
|
3481
|
+
return nil, "mcp_actions_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(actions_lib) .. ")"
|
|
3037
3482
|
end
|
|
3038
|
-
|
|
3483
|
+
local ok, res, err = pcall(fn)
|
|
3484
|
+
if not ok then return nil, tostring(res) end
|
|
3485
|
+
return res, err
|
|
3039
3486
|
end
|
|
3040
3487
|
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
regions[#regions + 1] = {
|
|
3049
|
-
index = markrgnindexnumber,
|
|
3050
|
-
name = name,
|
|
3051
|
-
start = pos,
|
|
3052
|
-
["end"] = rgnend,
|
|
3053
|
-
length = rgnend - pos,
|
|
3054
|
-
color = color,
|
|
3055
|
-
}
|
|
3056
|
-
end
|
|
3057
|
-
end
|
|
3058
|
-
return { regions = regions, count = #regions }
|
|
3488
|
+
-- Arrays arrive as JSON strings: the flat command parser cannot read nested arrays.
|
|
3489
|
+
local function decode_list(value, field)
|
|
3490
|
+
if type(value) == "table" then return value end
|
|
3491
|
+
if type(value) ~= "string" or not plan_lib_ok then return nil, field .. " must be a JSON array" end
|
|
3492
|
+
local list, err = plan_lib.json_decode(value)
|
|
3493
|
+
if type(list) ~= "table" then return nil, field .. " is not a valid JSON array" .. (err and (" (" .. err .. ")") or "") end
|
|
3494
|
+
return list
|
|
3059
3495
|
end
|
|
3060
3496
|
|
|
3061
|
-
function handlers.
|
|
3062
|
-
|
|
3063
|
-
local color = params.color or 0
|
|
3064
|
-
local name = params.name or ""
|
|
3065
|
-
reaper.Undo_BeginBlock()
|
|
3066
|
-
local idx = reaper.AddProjectMarker2(0, false, params.position, 0, name, -1, color)
|
|
3067
|
-
if idx < 0 then
|
|
3068
|
-
reaper.Undo_EndBlock("MCP: Add marker (failed)", -1)
|
|
3069
|
-
return nil, "Failed to add marker"
|
|
3070
|
-
end
|
|
3071
|
-
reaper.Undo_EndBlock("MCP: Add marker", -1)
|
|
3072
|
-
return { index = idx, position = params.position, name = name }
|
|
3497
|
+
function handlers.search_actions(params)
|
|
3498
|
+
return with_actions_lib(function() return actions_lib.search(reaper, params, read_kb_ini()) end)
|
|
3073
3499
|
end
|
|
3074
3500
|
|
|
3075
|
-
function handlers.
|
|
3076
|
-
|
|
3077
|
-
if params["end"] == nil then return nil, "end required" end
|
|
3078
|
-
local color = params.color or 0
|
|
3079
|
-
local name = params.name or ""
|
|
3080
|
-
reaper.Undo_BeginBlock()
|
|
3081
|
-
local idx = reaper.AddProjectMarker2(0, true, params.start, params["end"], name, -1, color)
|
|
3082
|
-
if idx < 0 then
|
|
3083
|
-
reaper.Undo_EndBlock("MCP: Add region (failed)", -1)
|
|
3084
|
-
return nil, "Failed to add region"
|
|
3085
|
-
end
|
|
3086
|
-
reaper.Undo_EndBlock("MCP: Add region", -1)
|
|
3087
|
-
return { index = idx, start = params.start, ["end"] = params["end"], name = name }
|
|
3501
|
+
function handlers.get_action(params)
|
|
3502
|
+
return with_actions_lib(function() return actions_lib.get(reaper, params, read_kb_ini()) end)
|
|
3088
3503
|
end
|
|
3089
3504
|
|
|
3090
|
-
function handlers.
|
|
3091
|
-
|
|
3092
|
-
reaper.Undo_BeginBlock()
|
|
3093
|
-
-- DeleteProjectMarker takes (proj, isrgn, markrgnindexnumber)
|
|
3094
|
-
local deleted = reaper.DeleteProjectMarker(0, false, params.markerIndex, false)
|
|
3095
|
-
if not deleted then
|
|
3096
|
-
reaper.Undo_EndBlock("MCP: Delete marker (failed)", -1)
|
|
3097
|
-
return nil, "Marker " .. params.markerIndex .. " not found"
|
|
3098
|
-
end
|
|
3099
|
-
reaper.Undo_EndBlock("MCP: Delete marker", -1)
|
|
3100
|
-
return { success = true }
|
|
3505
|
+
function handlers.run_action(params)
|
|
3506
|
+
return with_actions_lib(function() return actions_lib.run(reaper, params, read_kb_ini()) end)
|
|
3101
3507
|
end
|
|
3102
3508
|
|
|
3103
|
-
function handlers.
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3509
|
+
function handlers.set_selected_tracks(params)
|
|
3510
|
+
return with_actions_lib(function()
|
|
3511
|
+
if not plan_lib_ok then return nil, "mcp_plan_lib.lua is not installed next to mcp_bridge.lua" end
|
|
3512
|
+
local refs, err = decode_list(params.tracks, "tracks")
|
|
3513
|
+
if not refs then return nil, err end
|
|
3514
|
+
return actions_lib.set_selected_tracks(reaper, refs, params.mode, plan_lib.resolve_track)
|
|
3515
|
+
end)
|
|
3516
|
+
end
|
|
3517
|
+
|
|
3518
|
+
function handlers.set_selected_items(params)
|
|
3519
|
+
return with_actions_lib(function()
|
|
3520
|
+
local items, err = decode_list(params.items, "items")
|
|
3521
|
+
if not items then return nil, err end
|
|
3522
|
+
return actions_lib.set_selected_items(reaper, items, params.mode)
|
|
3523
|
+
end)
|
|
3524
|
+
end
|
|
3525
|
+
|
|
3526
|
+
-- =============================================================================
|
|
3527
|
+
-- Recording setup, fixed lanes and comping (logic in mcp_recording_lib.lua)
|
|
3528
|
+
-- =============================================================================
|
|
3529
|
+
|
|
3530
|
+
do -- locals scoped to this block: the main chunk is near Lua's 200-locals limit
|
|
3531
|
+
function ext.with_recording(fn)
|
|
3532
|
+
if ext.recording.load_error then
|
|
3533
|
+
return nil, "mcp_recording_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. ext.recording.load_error .. ")"
|
|
3534
|
+
end
|
|
3535
|
+
if not plan_lib_ok then return nil, "mcp_plan_lib.lua is not installed next to mcp_bridge.lua" end
|
|
3536
|
+
local ok, res, err = pcall(fn)
|
|
3537
|
+
if not ok then return nil, tostring(res) end
|
|
3538
|
+
return res, err
|
|
3539
|
+
end
|
|
3540
|
+
|
|
3541
|
+
-- Saved plugin state and extension commands (logic in mcp_fxstate_lib.lua / mcp_extensions_lib.lua).
|
|
3542
|
+
-- State travels as files in the bridge folder, never in the command JSON: it is megabytes.
|
|
3543
|
+
function ext.lib_call(key, fn, params, dir)
|
|
3544
|
+
local lib = ext[key]
|
|
3545
|
+
if lib.load_error then
|
|
3546
|
+
return nil, "mcp_" .. key .. "_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. lib.load_error .. ")"
|
|
3547
|
+
end
|
|
3548
|
+
if not plan_lib_ok then return nil, "mcp_plan_lib.lua is not installed next to mcp_bridge.lua" end
|
|
3549
|
+
local ctx = {
|
|
3550
|
+
plan = plan_lib, dir = dir or bridge_dir, read = read_file, fxstate = ext.fxstate,
|
|
3551
|
+
write = function(path, text)
|
|
3552
|
+
local f, err = io.open(path, "wb")
|
|
3553
|
+
if not f then return nil, err end
|
|
3554
|
+
f:write(text)
|
|
3555
|
+
f:close()
|
|
3556
|
+
return true
|
|
3557
|
+
end,
|
|
3558
|
+
}
|
|
3559
|
+
local ok, res, err = pcall(lib[fn], reaper, params, ctx)
|
|
3560
|
+
if not ok then return nil, "Handler error: " .. tostring(res) end
|
|
3561
|
+
return res, err
|
|
3562
|
+
end
|
|
3563
|
+
function handlers.get_fx_state(params) return ext.lib_call("fxstate", "get_fx_state", params) end
|
|
3564
|
+
function handlers.set_fx_state(params) return ext.lib_call("fxstate", "set_fx_state", params) end
|
|
3565
|
+
function handlers.extension_call(params) return ext.lib_call("extensions", "call", params, bridge_dir .. "extensions/") end
|
|
3566
|
+
|
|
3567
|
+
-- A JSON-encoded value (number, array or object) from the flat parser
|
|
3568
|
+
function ext.decode_json(value, field)
|
|
3569
|
+
if value == nil or type(value) == "table" or type(value) == "number" then return value end
|
|
3570
|
+
local v, err = plan_lib.json_decode(tostring(value))
|
|
3571
|
+
if v == nil then return nil, field .. " is not valid JSON" .. (err and (" (" .. err .. ")") or "") end
|
|
3572
|
+
return v
|
|
3573
|
+
end
|
|
3574
|
+
|
|
3575
|
+
function handlers.get_record_setup(params)
|
|
3576
|
+
return ext.with_recording(function()
|
|
3577
|
+
return ext.recording.get_record_setup(reaper, { all = params.all == true or params.all == "true" })
|
|
3578
|
+
end)
|
|
3579
|
+
end
|
|
3580
|
+
|
|
3581
|
+
function handlers.set_track_record(params)
|
|
3582
|
+
return ext.with_recording(function()
|
|
3583
|
+
local list, err = decode_list(params.tracks, "tracks")
|
|
3584
|
+
if not list then return nil, err end
|
|
3585
|
+
return ext.recording.set_track_record(reaper, list, plan_lib.resolve_track)
|
|
3586
|
+
end)
|
|
3587
|
+
end
|
|
3588
|
+
|
|
3589
|
+
function handlers.set_record_mode(params)
|
|
3590
|
+
return ext.with_recording(function() return ext.recording.set_record_mode(reaper, params) end)
|
|
3591
|
+
end
|
|
3592
|
+
|
|
3593
|
+
function handlers.get_track_lanes(params)
|
|
3594
|
+
return ext.with_recording(function() return ext.recording.get_track_lanes(reaper, params, plan_lib.resolve_track) end)
|
|
3595
|
+
end
|
|
3596
|
+
|
|
3597
|
+
function handlers.set_track_lanes(params)
|
|
3598
|
+
return ext.with_recording(function()
|
|
3599
|
+
local p = { track = params.track, laneCount = params.laneCount }
|
|
3600
|
+
if params.fixedLanes ~= nil then p.fixedLanes = params.fixedLanes == true or params.fixedLanes == "true" end
|
|
3601
|
+
local err
|
|
3602
|
+
p.names, err = ext.decode_json(params.names, "names")
|
|
3603
|
+
if err then return nil, err end
|
|
3604
|
+
p.play, err = ext.decode_json(params.play, "play")
|
|
3605
|
+
if err then return nil, err end
|
|
3606
|
+
return ext.recording.set_track_lanes(reaper, p, plan_lib.resolve_track)
|
|
3607
|
+
end)
|
|
3608
|
+
end
|
|
3609
|
+
|
|
3610
|
+
function handlers.comp_lanes(params)
|
|
3611
|
+
return ext.with_recording(function()
|
|
3612
|
+
local ranges, err = decode_list(params.ranges, "ranges")
|
|
3613
|
+
if not ranges then return nil, err end
|
|
3614
|
+
return ext.recording.comp_lanes(reaper, {
|
|
3615
|
+
track = params.track, ranges = ranges, crossfadeMs = params.crossfadeMs, name = params.name,
|
|
3616
|
+
}, plan_lib.resolve_track)
|
|
3617
|
+
end)
|
|
3618
|
+
end
|
|
3619
|
+
end
|
|
3620
|
+
|
|
3621
|
+
-- =============================================================================
|
|
3622
|
+
-- Track structure + routing setters (logic in mcp_tracks_lib.lua)
|
|
3623
|
+
-- =============================================================================
|
|
3624
|
+
|
|
3625
|
+
-- Resolve `params[field]` (index, name, GUID or "master") then run fn(track).
|
|
3626
|
+
local function with_tracks_lib(fn)
|
|
3627
|
+
if not tracks_lib_ok then
|
|
3628
|
+
return nil, "mcp_tracks_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(tracks_lib) .. ")"
|
|
3629
|
+
end
|
|
3630
|
+
if not plan_lib_ok then return nil, "mcp_plan_lib.lua is not installed next to mcp_bridge.lua" end
|
|
3631
|
+
local ok, res, err = pcall(fn)
|
|
3632
|
+
if not ok then return nil, tostring(res) end
|
|
3633
|
+
return res, err
|
|
3634
|
+
end
|
|
3635
|
+
|
|
3636
|
+
local function track_param(params, field)
|
|
3637
|
+
local ref = params[field]
|
|
3638
|
+
if ref == nil then return nil, field .. " required" end
|
|
3639
|
+
local res, err = plan_lib.resolve_track(reaper, ref, {})
|
|
3640
|
+
if not res then return nil, field .. ": " .. err end
|
|
3641
|
+
return res.track
|
|
3642
|
+
end
|
|
3643
|
+
|
|
3644
|
+
-- Run one structural edit in its own undo block, with a refreshed track list.
|
|
3645
|
+
local function undoable(label, fn)
|
|
3646
|
+
reaper.Undo_BeginBlock()
|
|
3647
|
+
reaper.PreventUIRefresh(1)
|
|
3648
|
+
local ok, res, err = pcall(fn)
|
|
3649
|
+
reaper.PreventUIRefresh(-1)
|
|
3650
|
+
reaper.Undo_EndBlock("MCP: " .. label, -1)
|
|
3651
|
+
reaper.TrackList_AdjustWindows(false)
|
|
3652
|
+
reaper.UpdateArrange()
|
|
3653
|
+
if not ok then return nil, tostring(res) end
|
|
3654
|
+
return res, err
|
|
3655
|
+
end
|
|
3656
|
+
|
|
3657
|
+
local function as_bool(v)
|
|
3658
|
+
if v == nil then return nil end
|
|
3659
|
+
return v == true or v == 1 or v == "true" or v == "1"
|
|
3660
|
+
end
|
|
3661
|
+
|
|
3662
|
+
function handlers.add_track(params)
|
|
3663
|
+
return with_tracks_lib(function()
|
|
3664
|
+
return undoable("Add track", function() return tracks_lib.add_track(reaper, params) end)
|
|
3665
|
+
end)
|
|
3666
|
+
end
|
|
3667
|
+
|
|
3668
|
+
function handlers.delete_track(params)
|
|
3669
|
+
return with_tracks_lib(function()
|
|
3670
|
+
local tr, err = track_param(params, "track")
|
|
3671
|
+
if not tr then return nil, err end
|
|
3672
|
+
return undoable("Delete track", function() return tracks_lib.delete_track(reaper, tr) end)
|
|
3673
|
+
end)
|
|
3674
|
+
end
|
|
3675
|
+
|
|
3676
|
+
function handlers.move_track(params)
|
|
3677
|
+
return with_tracks_lib(function()
|
|
3678
|
+
local tr, err = track_param(params, "track")
|
|
3679
|
+
if not tr then return nil, err end
|
|
3680
|
+
return undoable("Move track", function() return tracks_lib.move_track(reaper, tr, params.toIndex) end)
|
|
3681
|
+
end)
|
|
3682
|
+
end
|
|
3683
|
+
|
|
3684
|
+
function handlers.set_track_folder(params)
|
|
3685
|
+
return with_tracks_lib(function()
|
|
3686
|
+
local tr, err = track_param(params, "track")
|
|
3687
|
+
if not tr then return nil, err end
|
|
3688
|
+
return undoable("Set track folder", function() return tracks_lib.set_track_folder(reaper, tr, params) end)
|
|
3689
|
+
end)
|
|
3690
|
+
end
|
|
3691
|
+
|
|
3692
|
+
function handlers.set_track_info(params)
|
|
3693
|
+
return with_tracks_lib(function()
|
|
3694
|
+
local tr, err = track_param(params, "track")
|
|
3695
|
+
if not tr then return nil, err end
|
|
3696
|
+
params.masterSend = as_bool(params.masterSend)
|
|
3697
|
+
return undoable("Set track info", function() return tracks_lib.set_track_info(reaper, tr, params) end)
|
|
3698
|
+
end)
|
|
3699
|
+
end
|
|
3700
|
+
|
|
3701
|
+
function handlers.add_send(params)
|
|
3702
|
+
return with_tracks_lib(function()
|
|
3703
|
+
local src, e1 = track_param(params, "from")
|
|
3704
|
+
if not src then return nil, e1 end
|
|
3705
|
+
local dst, e2 = track_param(params, "to")
|
|
3706
|
+
if not dst then return nil, e2 end
|
|
3707
|
+
params.mute = as_bool(params.mute)
|
|
3708
|
+
return undoable("Add send", function() return tracks_lib.add_send(reaper, src, dst, params) end)
|
|
3709
|
+
end)
|
|
3710
|
+
end
|
|
3711
|
+
|
|
3712
|
+
function handlers.add_hardware_output(params)
|
|
3713
|
+
return with_tracks_lib(function()
|
|
3714
|
+
local tr, err = track_param(params, "track")
|
|
3715
|
+
if not tr then return nil, err end
|
|
3716
|
+
params.mute = as_bool(params.mute)
|
|
3717
|
+
params.mono = as_bool(params.mono)
|
|
3718
|
+
return undoable("Add hardware output", function() return tracks_lib.add_hardware_output(reaper, tr, params) end)
|
|
3719
|
+
end)
|
|
3720
|
+
end
|
|
3721
|
+
|
|
3722
|
+
function handlers.set_send(params)
|
|
3723
|
+
return with_tracks_lib(function()
|
|
3724
|
+
local tr, err = track_param(params, "track")
|
|
3725
|
+
if not tr then return nil, err end
|
|
3726
|
+
params.mute = as_bool(params.mute)
|
|
3727
|
+
params.mono = as_bool(params.mono)
|
|
3728
|
+
return undoable("Edit send", function() return tracks_lib.set_send(reaper, tr, params) end)
|
|
3729
|
+
end)
|
|
3730
|
+
end
|
|
3731
|
+
|
|
3732
|
+
function handlers.remove_send(params)
|
|
3733
|
+
return with_tracks_lib(function()
|
|
3734
|
+
local tr, err = track_param(params, "track")
|
|
3735
|
+
if not tr then return nil, err end
|
|
3736
|
+
return undoable("Remove send", function() return tracks_lib.remove_send(reaper, tr, params) end)
|
|
3737
|
+
end)
|
|
3738
|
+
end
|
|
3739
|
+
|
|
3740
|
+
function handlers.set_track_group(params)
|
|
3741
|
+
return with_tracks_lib(function()
|
|
3742
|
+
local tr, err = track_param(params, "track")
|
|
3743
|
+
if not tr then return nil, err end
|
|
3744
|
+
local roles, rerr = decode_list(params.roles, "roles")
|
|
3745
|
+
if not roles then return nil, rerr end
|
|
3746
|
+
params.roles = roles
|
|
3747
|
+
params.enable = as_bool(params.enable)
|
|
3748
|
+
return undoable("Set track group", function() return tracks_lib.set_track_group(reaper, tr, params) end)
|
|
3749
|
+
end)
|
|
3750
|
+
end
|
|
3751
|
+
|
|
3752
|
+
-- get_track_routing keeps its fields and gains what the setters change:
|
|
3753
|
+
-- send index/mode/channels, hardware outputs, master send, channel count.
|
|
3754
|
+
do
|
|
3755
|
+
local base = handlers.get_track_routing
|
|
3756
|
+
handlers.get_track_routing = function(params)
|
|
3757
|
+
local res, err = base(params)
|
|
3758
|
+
if not res or not tracks_lib_ok then return res, err end
|
|
3759
|
+
local tr = reaper.GetTrack(0, params.trackIndex)
|
|
3760
|
+
local ok, extra = pcall(tracks_lib.routing, reaper, tr)
|
|
3761
|
+
if not ok then return res end
|
|
3762
|
+
for k, s in ipairs(res.sends or {}) do
|
|
3763
|
+
local x = extra.sends[k]
|
|
3764
|
+
if x then s.index, s.mode, s.srcChannel, s.destChannel = x.index, x.mode, x.srcChannel, x.destChannel end
|
|
3765
|
+
end
|
|
3766
|
+
for k, r in ipairs(res.receives or {}) do
|
|
3767
|
+
local x = extra.receives[k]
|
|
3768
|
+
if x then r.index, r.mode, r.srcChannel, r.destChannel = x.index, x.mode, x.srcChannel, x.destChannel end
|
|
3769
|
+
end
|
|
3770
|
+
res.hardwareOutputs = extra.hardwareOutputs
|
|
3771
|
+
res.masterSend = extra.masterSend
|
|
3772
|
+
res.channels = extra.channels
|
|
3773
|
+
return res
|
|
3774
|
+
end
|
|
3775
|
+
end
|
|
3776
|
+
|
|
3777
|
+
-- =============================================================================
|
|
3778
|
+
-- Project ops, tempo map and marker editing (logic in mcp_project_lib.lua)
|
|
3779
|
+
-- =============================================================================
|
|
3780
|
+
|
|
3781
|
+
local function with_project_lib(fn)
|
|
3782
|
+
if not project_lib_ok then
|
|
3783
|
+
return nil, "mcp_project_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(project_lib) .. ")"
|
|
3784
|
+
end
|
|
3785
|
+
local ok, res, err = pcall(fn)
|
|
3786
|
+
if not ok then return nil, tostring(res) end
|
|
3787
|
+
return res, err
|
|
3788
|
+
end
|
|
3789
|
+
|
|
3790
|
+
-- Real file system for save/render checks.
|
|
3791
|
+
local project_fs = {
|
|
3792
|
+
exists = function(path)
|
|
3793
|
+
local f = io.open(path, "rb")
|
|
3794
|
+
if f then f:close() return true end
|
|
3795
|
+
return false
|
|
3796
|
+
end,
|
|
3797
|
+
dir_exists = function(path)
|
|
3798
|
+
return os.rename(path, path) == true
|
|
3799
|
+
end,
|
|
3800
|
+
remove = function(path) os.remove(path) end,
|
|
3801
|
+
}
|
|
3802
|
+
|
|
3803
|
+
function handlers.undo_project(params)
|
|
3804
|
+
return with_project_lib(function() return project_lib.undo(reaper) end)
|
|
3805
|
+
end
|
|
3806
|
+
|
|
3807
|
+
function handlers.redo_project(params)
|
|
3808
|
+
return with_project_lib(function() return project_lib.redo(reaper) end)
|
|
3809
|
+
end
|
|
3810
|
+
|
|
3811
|
+
function handlers.save_project(params)
|
|
3812
|
+
return with_project_lib(function() return project_lib.save(reaper, params, project_fs) end)
|
|
3813
|
+
end
|
|
3814
|
+
|
|
3815
|
+
function handlers.get_render_settings(params)
|
|
3816
|
+
return with_project_lib(function() return project_lib.get_render(reaper) end)
|
|
3817
|
+
end
|
|
3818
|
+
|
|
3819
|
+
function handlers.set_render_settings(params)
|
|
3820
|
+
return with_project_lib(function()
|
|
3821
|
+
local p, err = project_lib.decode_json_fields(params, plan_lib_ok and plan_lib.json_decode or function() return nil end)
|
|
3822
|
+
if not p then return nil, err end
|
|
3823
|
+
return project_lib.set_render(reaper, p, plan_lib_ok and plan_lib.resolve_track or nil)
|
|
3824
|
+
end)
|
|
3825
|
+
end
|
|
3826
|
+
|
|
3827
|
+
function handlers.render_project(params)
|
|
3828
|
+
return with_project_lib(function()
|
|
3829
|
+
local p, err = project_lib.decode_json_fields(params, plan_lib_ok and plan_lib.json_decode or function() return nil end)
|
|
3830
|
+
if not p then return nil, err end
|
|
3831
|
+
return project_lib.render(reaper, p, project_fs, plan_lib_ok and plan_lib.resolve_track or nil)
|
|
3832
|
+
end)
|
|
3833
|
+
end
|
|
3834
|
+
|
|
3835
|
+
function handlers.render_versions(params)
|
|
3836
|
+
if not plan_lib_ok then return nil, "mcp_plan_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup`" end
|
|
3837
|
+
return with_project_lib(function()
|
|
3838
|
+
local p, err = project_lib.decode_json_fields(params, plan_lib.json_decode)
|
|
3839
|
+
if not p then return nil, err end
|
|
3840
|
+
return project_lib.render_versions(reaper, p, project_fs, plan_lib.resolve_track)
|
|
3841
|
+
end)
|
|
3842
|
+
end
|
|
3843
|
+
|
|
3844
|
+
function handlers.get_project_settings(params)
|
|
3845
|
+
return with_project_lib(function() return project_lib.get_settings(reaper) end)
|
|
3846
|
+
end
|
|
3847
|
+
|
|
3848
|
+
function handlers.set_project_settings(params)
|
|
3849
|
+
return with_project_lib(function() return project_lib.set_settings(reaper, params) end)
|
|
3850
|
+
end
|
|
3851
|
+
|
|
3852
|
+
function handlers.add_tempo_marker(params)
|
|
3853
|
+
return with_project_lib(function() return project_lib.add_tempo_marker(reaper, params) end)
|
|
3854
|
+
end
|
|
3855
|
+
|
|
3856
|
+
function handlers.edit_tempo_marker(params)
|
|
3857
|
+
return with_project_lib(function() return project_lib.edit_tempo_marker(reaper, params) end)
|
|
3858
|
+
end
|
|
3859
|
+
|
|
3860
|
+
function handlers.delete_tempo_marker(params)
|
|
3861
|
+
return with_project_lib(function() return project_lib.delete_tempo_marker(reaper, params) end)
|
|
3862
|
+
end
|
|
3863
|
+
|
|
3864
|
+
function handlers.set_project_tempo(params)
|
|
3865
|
+
return with_project_lib(function() return project_lib.set_tempo(reaper, params) end)
|
|
3866
|
+
end
|
|
3867
|
+
|
|
3868
|
+
function handlers.edit_marker(params)
|
|
3869
|
+
return with_project_lib(function() return project_lib.edit_marker(reaper, params) end)
|
|
3870
|
+
end
|
|
3871
|
+
|
|
3872
|
+
function handlers.edit_region(params)
|
|
3873
|
+
return with_project_lib(function() return project_lib.edit_region(reaper, params) end)
|
|
3874
|
+
end
|
|
3875
|
+
|
|
3876
|
+
-- =============================================================================
|
|
3877
|
+
-- Items, takes, take FX and FX housekeeping (logic in mcp_items_lib.lua)
|
|
3878
|
+
-- =============================================================================
|
|
3879
|
+
|
|
3880
|
+
local items_fs = {
|
|
3881
|
+
exists = function(path) return reaper.file_exists(path) end,
|
|
3882
|
+
read = function(path) return read_file(path) end,
|
|
3883
|
+
write = function(path, text)
|
|
3884
|
+
local f, err = io.open(path, "w")
|
|
3885
|
+
if not f then return nil, err end
|
|
3886
|
+
f:write(text)
|
|
3887
|
+
f:close()
|
|
3888
|
+
return true
|
|
3889
|
+
end,
|
|
3890
|
+
mkdir = function(dir) reaper.RecursiveCreateDirectory(dir, 0) end,
|
|
3891
|
+
list = function(dir)
|
|
3892
|
+
local files, dirs = {}, {}
|
|
3893
|
+
-- Index -1 drops REAPER's cached listing so new files show up.
|
|
3894
|
+
reaper.EnumerateFiles(dir, -1)
|
|
3895
|
+
reaper.EnumerateSubdirectories(dir, -1)
|
|
3896
|
+
local i = 0
|
|
3897
|
+
while true do
|
|
3898
|
+
local f = reaper.EnumerateFiles(dir, i)
|
|
3899
|
+
if not f then break end
|
|
3900
|
+
files[#files + 1] = f
|
|
3901
|
+
i = i + 1
|
|
3902
|
+
end
|
|
3903
|
+
i = 0
|
|
3904
|
+
while true do
|
|
3905
|
+
local d = reaper.EnumerateSubdirectories(dir, i)
|
|
3906
|
+
if not d then break end
|
|
3907
|
+
dirs[#dirs + 1] = d
|
|
3908
|
+
i = i + 1
|
|
3909
|
+
end
|
|
3910
|
+
return files, dirs
|
|
3911
|
+
end,
|
|
3912
|
+
}
|
|
3913
|
+
|
|
3914
|
+
local function with_items_lib(name, params)
|
|
3915
|
+
if not items_lib_ok then
|
|
3916
|
+
return nil, "mcp_items_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(items_lib) .. ")"
|
|
3917
|
+
end
|
|
3918
|
+
if not plan_lib_ok then return nil, "mcp_plan_lib.lua is not installed next to mcp_bridge.lua" end
|
|
3919
|
+
local ok, res, err = pcall(items_lib[name], reaper, params, { plan = plan_lib, fs = items_fs })
|
|
3920
|
+
if not ok then return nil, tostring(res) end
|
|
3921
|
+
return res, err
|
|
3922
|
+
end
|
|
3923
|
+
|
|
3924
|
+
function handlers.insert_audio_file(params) return with_items_lib("insert_audio_file", params) end
|
|
3925
|
+
function handlers.insert_stem_tracks(params)
|
|
3926
|
+
-- files travels as a JSON string (the bridge decodes only the top level)
|
|
3927
|
+
if type(params.files) == "string" and plan_lib_ok then
|
|
3928
|
+
local ok, files = pcall(plan_lib.json_decode, params.files)
|
|
3929
|
+
params.files = ok and files or nil
|
|
3930
|
+
end
|
|
3931
|
+
return with_items_lib("insert_stems", params)
|
|
3932
|
+
end
|
|
3933
|
+
function handlers.list_takes(params) return with_items_lib("list_takes", params) end
|
|
3934
|
+
function handlers.set_active_take(params) return with_items_lib("set_active_take", params) end
|
|
3935
|
+
function handlers.get_take_fx(params) return with_items_lib("get_take_fx", params) end
|
|
3936
|
+
function handlers.add_take_fx(params) return with_items_lib("add_take_fx", params) end
|
|
3937
|
+
function handlers.remove_take_fx(params) return with_items_lib("remove_take_fx", params) end
|
|
3938
|
+
function handlers.set_take_fx_parameter(params) return with_items_lib("set_take_fx_parameter", params) end
|
|
3939
|
+
function handlers.get_item_appearance(params) return with_items_lib("get_item_appearance", params) end
|
|
3940
|
+
function handlers.set_item_color(params) return with_items_lib("set_item_color", params) end
|
|
3941
|
+
function handlers.set_item_fades(params) return with_items_lib("set_item_fades", params) end
|
|
3942
|
+
function handlers.move_fx(params) return with_items_lib("move_fx", params) end
|
|
3943
|
+
function handlers.copy_fx(params) return with_items_lib("copy_fx", params) end
|
|
3944
|
+
function handlers.rename_fx(params) return with_items_lib("rename_fx", params) end
|
|
3945
|
+
function handlers.save_fx_chain(params) return with_items_lib("save_fx_chain", params) end
|
|
3946
|
+
function handlers.load_fx_chain(params) return with_items_lib("load_fx_chain", params) end
|
|
3947
|
+
function handlers.list_fx_chains(params) return with_items_lib("list_fx_chains", params) end
|
|
3948
|
+
function handlers.save_track_template(params) return with_items_lib("save_track_template", params) end
|
|
3949
|
+
function handlers.insert_track_template(params) return with_items_lib("insert_track_template", params) end
|
|
3950
|
+
function handlers.list_track_templates(params) return with_items_lib("list_track_templates", params) end
|
|
3951
|
+
|
|
3952
|
+
-- =============================================================================
|
|
3953
|
+
-- Selection & Navigation
|
|
3954
|
+
-- =============================================================================
|
|
3955
|
+
|
|
3956
|
+
function handlers.get_selected_tracks(params)
|
|
3957
|
+
-- The *2 variants include the master (IP_TRACKNUMBER -1 -> index -1).
|
|
3958
|
+
local count = reaper.CountSelectedTracks2(0, true)
|
|
3959
|
+
local tracks = {}
|
|
3960
|
+
for i = 0, count - 1 do
|
|
3961
|
+
local track = reaper.GetSelectedTrack2(0, i, true)
|
|
3962
|
+
local num = reaper.GetMediaTrackInfo_Value(track, "IP_TRACKNUMBER")
|
|
3963
|
+
local idx = num == -1 and -1 or num - 1
|
|
3964
|
+
local _, name = reaper.GetTrackName(track)
|
|
3965
|
+
tracks[#tracks + 1] = { index = idx, name = name }
|
|
3966
|
+
end
|
|
3967
|
+
return { tracks = tracks, count = count }
|
|
3968
|
+
end
|
|
3969
|
+
|
|
3970
|
+
function handlers.get_time_selection(params)
|
|
3971
|
+
local start_pos, end_pos = reaper.GetSet_LoopTimeRange(false, false, 0, 0, false)
|
|
3972
|
+
return {
|
|
3973
|
+
start = start_pos,
|
|
3974
|
+
["end"] = end_pos,
|
|
3975
|
+
length = end_pos - start_pos,
|
|
3976
|
+
hasSelection = end_pos > start_pos,
|
|
3977
|
+
}
|
|
3978
|
+
end
|
|
3979
|
+
|
|
3980
|
+
function handlers.set_time_selection(params)
|
|
3981
|
+
if params.start == nil then return nil, "start required" end
|
|
3982
|
+
if params["end"] == nil then return nil, "end required" end
|
|
3983
|
+
reaper.Undo_BeginBlock()
|
|
3984
|
+
reaper.GetSet_LoopTimeRange(true, false, params.start, params["end"], false)
|
|
3985
|
+
reaper.Undo_EndBlock("MCP: Set time selection", -1)
|
|
3986
|
+
return { start = params.start, ["end"] = params["end"] }
|
|
3987
|
+
end
|
|
3988
|
+
|
|
3989
|
+
-- =============================================================================
|
|
3990
|
+
-- Markers & Regions
|
|
3991
|
+
-- =============================================================================
|
|
3992
|
+
|
|
3993
|
+
function handlers.list_markers(params)
|
|
3994
|
+
local _, num_markers, num_regions = reaper.CountProjectMarkers(0)
|
|
3995
|
+
local total = num_markers + num_regions
|
|
3996
|
+
local markers = {}
|
|
3997
|
+
for i = 0, total - 1 do
|
|
3998
|
+
local _, isrgn, pos, rgnend, name, markrgnindexnumber, color = reaper.EnumProjectMarkers3(0, i)
|
|
3999
|
+
if not isrgn then
|
|
4000
|
+
markers[#markers + 1] = {
|
|
4001
|
+
index = markrgnindexnumber,
|
|
4002
|
+
name = name,
|
|
4003
|
+
position = pos,
|
|
4004
|
+
color = color,
|
|
4005
|
+
}
|
|
4006
|
+
end
|
|
4007
|
+
end
|
|
4008
|
+
return { markers = markers, count = #markers }
|
|
4009
|
+
end
|
|
4010
|
+
|
|
4011
|
+
function handlers.list_regions(params)
|
|
4012
|
+
local _, num_markers, num_regions = reaper.CountProjectMarkers(0)
|
|
4013
|
+
local total = num_markers + num_regions
|
|
4014
|
+
local regions = {}
|
|
4015
|
+
for i = 0, total - 1 do
|
|
4016
|
+
local _, isrgn, pos, rgnend, name, markrgnindexnumber, color = reaper.EnumProjectMarkers3(0, i)
|
|
4017
|
+
if isrgn then
|
|
4018
|
+
regions[#regions + 1] = {
|
|
4019
|
+
index = markrgnindexnumber,
|
|
4020
|
+
name = name,
|
|
4021
|
+
start = pos,
|
|
4022
|
+
["end"] = rgnend,
|
|
4023
|
+
length = rgnend - pos,
|
|
4024
|
+
color = color,
|
|
4025
|
+
}
|
|
4026
|
+
end
|
|
4027
|
+
end
|
|
4028
|
+
return { regions = regions, count = #regions }
|
|
4029
|
+
end
|
|
4030
|
+
|
|
4031
|
+
function handlers.add_marker(params)
|
|
4032
|
+
if params.position == nil then return nil, "position required" end
|
|
4033
|
+
local color = params.color or 0
|
|
4034
|
+
local name = params.name or ""
|
|
4035
|
+
reaper.Undo_BeginBlock()
|
|
4036
|
+
local idx = reaper.AddProjectMarker2(0, false, params.position, 0, name, -1, color)
|
|
4037
|
+
if idx < 0 then
|
|
4038
|
+
reaper.Undo_EndBlock("MCP: Add marker (failed)", -1)
|
|
4039
|
+
return nil, "Failed to add marker"
|
|
4040
|
+
end
|
|
4041
|
+
reaper.Undo_EndBlock("MCP: Add marker", -1)
|
|
4042
|
+
return { index = idx, position = params.position, name = name }
|
|
4043
|
+
end
|
|
4044
|
+
|
|
4045
|
+
function handlers.add_region(params)
|
|
4046
|
+
if params.start == nil then return nil, "start required" end
|
|
4047
|
+
if params["end"] == nil then return nil, "end required" end
|
|
4048
|
+
local color = params.color or 0
|
|
4049
|
+
local name = params.name or ""
|
|
4050
|
+
reaper.Undo_BeginBlock()
|
|
4051
|
+
local idx = reaper.AddProjectMarker2(0, true, params.start, params["end"], name, -1, color)
|
|
4052
|
+
if idx < 0 then
|
|
4053
|
+
reaper.Undo_EndBlock("MCP: Add region (failed)", -1)
|
|
4054
|
+
return nil, "Failed to add region"
|
|
4055
|
+
end
|
|
4056
|
+
reaper.Undo_EndBlock("MCP: Add region", -1)
|
|
4057
|
+
return { index = idx, start = params.start, ["end"] = params["end"], name = name }
|
|
4058
|
+
end
|
|
4059
|
+
|
|
4060
|
+
function handlers.delete_marker(params)
|
|
4061
|
+
if params.markerIndex == nil then return nil, "markerIndex required" end
|
|
4062
|
+
reaper.Undo_BeginBlock()
|
|
4063
|
+
-- DeleteProjectMarker takes (proj, markrgnindexnumber, isrgn)
|
|
4064
|
+
local deleted = reaper.DeleteProjectMarker(0, params.markerIndex, false)
|
|
4065
|
+
if not deleted then
|
|
4066
|
+
reaper.Undo_EndBlock("MCP: Delete marker (failed)", -1)
|
|
4067
|
+
return nil, "Marker " .. params.markerIndex .. " not found"
|
|
4068
|
+
end
|
|
4069
|
+
reaper.Undo_EndBlock("MCP: Delete marker", -1)
|
|
4070
|
+
return { success = true }
|
|
4071
|
+
end
|
|
4072
|
+
|
|
4073
|
+
function handlers.delete_region(params)
|
|
4074
|
+
if params.regionIndex == nil then return nil, "regionIndex required" end
|
|
4075
|
+
reaper.Undo_BeginBlock()
|
|
4076
|
+
local deleted = reaper.DeleteProjectMarker(0, params.regionIndex, true)
|
|
4077
|
+
if not deleted then
|
|
4078
|
+
reaper.Undo_EndBlock("MCP: Delete region (failed)", -1)
|
|
4079
|
+
return nil, "Region " .. params.regionIndex .. " not found"
|
|
3110
4080
|
end
|
|
3111
4081
|
reaper.Undo_EndBlock("MCP: Delete region", -1)
|
|
3112
4082
|
return { success = true }
|
|
@@ -3133,7 +4103,7 @@ function handlers.get_tempo_map(params)
|
|
|
3133
4103
|
-- If no markers, return the project tempo as the single entry
|
|
3134
4104
|
if count == 0 then
|
|
3135
4105
|
local bpm = reaper.Master_GetTempo()
|
|
3136
|
-
local
|
|
4106
|
+
local num, denom = reaper.TimeMap_GetTimeSigAtTime(0, 0)
|
|
3137
4107
|
markers[1] = {
|
|
3138
4108
|
index = 0,
|
|
3139
4109
|
position = 0,
|
|
@@ -3150,212 +4120,105 @@ end
|
|
|
3150
4120
|
-- Envelopes / Automation
|
|
3151
4121
|
-- =============================================================================
|
|
3152
4122
|
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
local point_count = reaper.CountEnvelopePoints(env)
|
|
3162
|
-
-- Get envelope state info (SWS extension provides BR_Env*, fallback if unavailable)
|
|
3163
|
-
local active, visible, armed = true, true, false
|
|
3164
|
-
if reaper.BR_EnvAlloc then
|
|
3165
|
-
local br_env = reaper.BR_EnvAlloc(env, false)
|
|
3166
|
-
active, visible, armed = reaper.BR_EnvGetProperties(br_env)
|
|
3167
|
-
reaper.BR_EnvFree(br_env, false)
|
|
3168
|
-
end
|
|
3169
|
-
envelopes[#envelopes + 1] = {
|
|
3170
|
-
index = i,
|
|
3171
|
-
name = name,
|
|
3172
|
-
pointCount = point_count,
|
|
3173
|
-
active = active,
|
|
3174
|
-
visible = visible,
|
|
3175
|
-
armed = armed,
|
|
3176
|
-
}
|
|
4123
|
+
-- Points, creation and automation items: logic in mcp_envelopes_lib.lua. One table, not
|
|
4124
|
+
-- separate locals: the bridge's main chunk is at Lua's 200-locals limit.
|
|
4125
|
+
local env_ext = {}
|
|
4126
|
+
env_ext.ok, env_ext.lib = pcall(dofile, script_dir .. "mcp_envelopes_lib.lua")
|
|
4127
|
+
|
|
4128
|
+
function env_ext.run(name, params, ...)
|
|
4129
|
+
if not env_ext.ok then
|
|
4130
|
+
return nil, "mcp_envelopes_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(env_ext.lib) .. ")"
|
|
3177
4131
|
end
|
|
3178
|
-
|
|
4132
|
+
local ok, res, err = pcall(env_ext.lib[name], reaper, params, ...)
|
|
4133
|
+
if not ok then return nil, "Handler error: " .. tostring(res) end
|
|
4134
|
+
return res, err
|
|
3179
4135
|
end
|
|
3180
4136
|
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
if
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
return nil, "Envelope index " .. params.envelopeIndex .. " out of range (track has " .. env_count .. " envelopes)"
|
|
3187
|
-
end
|
|
3188
|
-
local env = reaper.GetTrackEnvelope(track, params.envelopeIndex)
|
|
3189
|
-
local total = reaper.CountEnvelopePoints(env)
|
|
3190
|
-
local offset = params.offset or 0
|
|
3191
|
-
local limit = params.limit or total
|
|
3192
|
-
local points = {}
|
|
3193
|
-
local end_idx = math.min(offset + limit, total)
|
|
3194
|
-
for i = offset, end_idx - 1 do
|
|
3195
|
-
local _, time, value, shape, tension, selected = reaper.GetEnvelopePoint(env, i)
|
|
3196
|
-
points[#points + 1] = {
|
|
3197
|
-
index = i,
|
|
3198
|
-
time = time,
|
|
3199
|
-
value = value,
|
|
3200
|
-
shape = shape,
|
|
3201
|
-
tension = tension,
|
|
3202
|
-
selected = selected,
|
|
3203
|
-
}
|
|
3204
|
-
end
|
|
3205
|
-
return {
|
|
3206
|
-
trackIndex = params.trackIndex,
|
|
3207
|
-
envelopeIndex = params.envelopeIndex,
|
|
3208
|
-
points = points,
|
|
3209
|
-
total = total,
|
|
3210
|
-
offset = offset,
|
|
3211
|
-
hasMore = end_idx < total,
|
|
3212
|
-
}
|
|
4137
|
+
-- A JSON array param arrives as a string (or, from some clients, already decoded)
|
|
4138
|
+
function env_ext.list(v)
|
|
4139
|
+
if type(v) == "table" then return v end
|
|
4140
|
+
if type(v) ~= "string" then return nil end
|
|
4141
|
+
return json_decode_array(v)
|
|
3213
4142
|
end
|
|
3214
4143
|
|
|
4144
|
+
function handlers.get_track_envelopes(params) return env_ext.run("list_envelopes", params) end
|
|
4145
|
+
|
|
4146
|
+
function handlers.get_envelope_points(params) return env_ext.run("get_points", params) end
|
|
4147
|
+
|
|
3215
4148
|
function handlers.insert_envelope_point(params)
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
if params.envelopeIndex >= env_count then
|
|
3220
|
-
return nil, "Envelope index " .. params.envelopeIndex .. " out of range (track has " .. env_count .. " envelopes)"
|
|
3221
|
-
end
|
|
3222
|
-
local env = reaper.GetTrackEnvelope(track, params.envelopeIndex)
|
|
3223
|
-
local shape = params.shape or 0
|
|
3224
|
-
local tension = params.tension or 0
|
|
3225
|
-
reaper.Undo_BeginBlock()
|
|
3226
|
-
reaper.InsertEnvelopePoint(env, params.time, params.value, shape, tension, false, true)
|
|
3227
|
-
reaper.Envelope_SortPoints(env)
|
|
3228
|
-
reaper.Undo_EndBlock("MCP: Insert envelope point", -1)
|
|
3229
|
-
local total = reaper.CountEnvelopePoints(env)
|
|
3230
|
-
return {
|
|
3231
|
-
trackIndex = params.trackIndex,
|
|
3232
|
-
envelopeIndex = params.envelopeIndex,
|
|
3233
|
-
time = params.time,
|
|
3234
|
-
value = params.value,
|
|
3235
|
-
shape = shape,
|
|
3236
|
-
tension = tension,
|
|
3237
|
-
totalPoints = total,
|
|
3238
|
-
}
|
|
4149
|
+
return env_ext.run("insert_points", params, {
|
|
4150
|
+
{ time = params.time, value = params.value, db = params.db, shape = params.shape, tension = params.tension },
|
|
4151
|
+
})
|
|
3239
4152
|
end
|
|
3240
4153
|
|
|
3241
|
-
function handlers.
|
|
3242
|
-
local
|
|
3243
|
-
if not
|
|
3244
|
-
|
|
3245
|
-
if params.envelopeIndex >= env_count then
|
|
3246
|
-
return nil, "Envelope index " .. params.envelopeIndex .. " out of range (track has " .. env_count .. " envelopes)"
|
|
3247
|
-
end
|
|
3248
|
-
local env = reaper.GetTrackEnvelope(track, params.envelopeIndex)
|
|
3249
|
-
local total = reaper.CountEnvelopePoints(env)
|
|
3250
|
-
if params.pointIndex >= total then
|
|
3251
|
-
return nil, "Point index " .. params.pointIndex .. " out of range (envelope has " .. total .. " points)"
|
|
3252
|
-
end
|
|
3253
|
-
reaper.Undo_BeginBlock()
|
|
3254
|
-
reaper.DeleteEnvelopePointRange(env, params.pointIndex, params.pointIndex + 1)
|
|
3255
|
-
reaper.Undo_EndBlock("MCP: Delete envelope point", -1)
|
|
3256
|
-
return { success = true, totalPoints = reaper.CountEnvelopePoints(env) }
|
|
4154
|
+
function handlers.insert_envelope_points(params)
|
|
4155
|
+
local points = env_ext.list(params.points)
|
|
4156
|
+
if not points then return nil, "Failed to parse points JSON" end
|
|
4157
|
+
return env_ext.run("insert_points", params, points)
|
|
3257
4158
|
end
|
|
3258
4159
|
|
|
3259
|
-
function handlers.
|
|
3260
|
-
local
|
|
3261
|
-
if not
|
|
4160
|
+
function handlers.edit_envelope_points(params)
|
|
4161
|
+
local points = env_ext.list(params.points)
|
|
4162
|
+
if not points then return nil, "Failed to parse points JSON" end
|
|
4163
|
+
return env_ext.run("edit_points", params, points)
|
|
4164
|
+
end
|
|
3262
4165
|
|
|
3263
|
-
|
|
4166
|
+
function handlers.delete_envelope_point(params) return env_ext.run("delete_point", params) end
|
|
3264
4167
|
|
|
3265
|
-
|
|
3266
|
-
local env_name = nil
|
|
3267
|
-
|
|
3268
|
-
if params.fxIndex ~= nil then
|
|
3269
|
-
-- FX parameter envelope
|
|
3270
|
-
if params.paramIndex == nil then
|
|
3271
|
-
return nil, "paramIndex is required when fxIndex is provided"
|
|
3272
|
-
end
|
|
3273
|
-
local fx_count = reaper.TrackFX_GetNumParms(track)
|
|
3274
|
-
if fx_count == 0 then
|
|
3275
|
-
return nil, "Track " .. params.trackIndex .. " has no FX"
|
|
3276
|
-
end
|
|
3277
|
-
-- create=true to create the envelope if it doesn't exist
|
|
3278
|
-
env = reaper.GetFXEnvelope(track, params.fxIndex, params.paramIndex, true)
|
|
3279
|
-
if not env then
|
|
3280
|
-
return nil, "Failed to create FX parameter envelope (fxIndex=" .. params.fxIndex .. ", paramIndex=" .. params.paramIndex .. ")"
|
|
3281
|
-
end
|
|
3282
|
-
local _, pname = reaper.TrackFX_GetParamName(track, params.fxIndex, params.paramIndex)
|
|
3283
|
-
local _, fname = reaper.TrackFX_GetFXName(track, params.fxIndex)
|
|
3284
|
-
env_name = fname .. " / " .. pname
|
|
3285
|
-
elseif params.envelopeName then
|
|
3286
|
-
-- Built-in envelope: use GetTrackEnvelopeByName + show via state chunk
|
|
3287
|
-
env = reaper.GetTrackEnvelopeByName(track, params.envelopeName)
|
|
3288
|
-
if not env then
|
|
3289
|
-
-- Some envelopes need to be activated first via the track state chunk
|
|
3290
|
-
-- Toggle the envelope visibility via action or chunk editing
|
|
3291
|
-
local env_map = {
|
|
3292
|
-
["Volume"] = "VOLENV",
|
|
3293
|
-
["Pan"] = "PANENV",
|
|
3294
|
-
["Mute"] = "MUTEENV",
|
|
3295
|
-
["Width"] = "WIDTHENV",
|
|
3296
|
-
["Trim Volume"] = "VOLENV2",
|
|
3297
|
-
}
|
|
3298
|
-
local chunk_key = env_map[params.envelopeName]
|
|
3299
|
-
if not chunk_key then
|
|
3300
|
-
return nil, "Unknown envelope name: " .. params.envelopeName .. ". Use Volume, Pan, Mute, Width, or Trim Volume"
|
|
3301
|
-
end
|
|
3302
|
-
-- Get track chunk, insert envelope chunk if missing
|
|
3303
|
-
-- Use anchored pattern "<VOLENV\n" to avoid matching VOLENV2 (Trim Volume)
|
|
3304
|
-
local _, chunk = reaper.GetTrackStateChunk(track, "", false)
|
|
3305
|
-
if not chunk:find("<" .. chunk_key .. "\n") then
|
|
3306
|
-
-- Insert a minimal envelope chunk before the closing >
|
|
3307
|
-
local env_chunk = "\n<" .. chunk_key .. "\nACT 1 -1\nVIS 1 1 1\nLANEHEIGHT 0 0\nARM 0\nDEFSHAPE 0 -1 -1\n>\n"
|
|
3308
|
-
-- Use position capture to find the last ">" (closing the <TRACK block).
|
|
3309
|
-
-- We cannot use "\n>$" because GetTrackStateChunk may include a trailing newline.
|
|
3310
|
-
local last_gt = chunk:match(".*()>")
|
|
3311
|
-
if last_gt then
|
|
3312
|
-
chunk = chunk:sub(1, last_gt - 1) .. env_chunk .. chunk:sub(last_gt)
|
|
3313
|
-
end
|
|
3314
|
-
reaper.SetTrackStateChunk(track, chunk, false)
|
|
3315
|
-
else
|
|
3316
|
-
-- Envelope exists in chunk but may be hidden; make it visible
|
|
3317
|
-
chunk = chunk:gsub("(<" .. chunk_key .. "[^\n]*\n)ACT 0", "%1ACT 1")
|
|
3318
|
-
chunk = chunk:gsub("(<" .. chunk_key .. "[^\n]*\n[^\n]*\n)VIS 0", "%1VIS 1")
|
|
3319
|
-
reaper.SetTrackStateChunk(track, chunk, false)
|
|
3320
|
-
end
|
|
3321
|
-
env = reaper.GetTrackEnvelopeByName(track, params.envelopeName)
|
|
3322
|
-
if not env then
|
|
3323
|
-
return nil, "Failed to create envelope: " .. params.envelopeName
|
|
3324
|
-
end
|
|
3325
|
-
else
|
|
3326
|
-
-- Envelope exists, ensure it's visible and active
|
|
3327
|
-
local _, chunk = reaper.GetEnvelopeStateChunk(env, "", false)
|
|
3328
|
-
chunk = chunk:gsub("ACT 0", "ACT 1")
|
|
3329
|
-
chunk = chunk:gsub("VIS 0", "VIS 1")
|
|
3330
|
-
reaper.SetEnvelopeStateChunk(env, chunk, false)
|
|
3331
|
-
end
|
|
3332
|
-
env_name = params.envelopeName
|
|
3333
|
-
else
|
|
3334
|
-
return nil, "Must provide either envelopeName or fxIndex+paramIndex"
|
|
3335
|
-
end
|
|
4168
|
+
function handlers.create_track_envelope(params) return env_ext.run("create", params) end
|
|
3336
4169
|
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
4170
|
+
-- Mixing depth (mcp_mixing_lib.lua): automation modes, FX parameter modulation, pins,
|
|
4171
|
+
-- monitoring FX, freeze / render in place, stretch markers. Nested params arrive as JSON strings.
|
|
4172
|
+
-- Lib wrappers added since the main chunk reached the 200-locals limit live here, one field each
|
|
4173
|
+
ext.mix = {}
|
|
4174
|
+
ext.mix.ok, ext.mix.lib = pcall(dofile, script_dir .. "mcp_mixing_lib.lua")
|
|
4175
|
+
|
|
4176
|
+
function ext.mix.decode(v)
|
|
4177
|
+
if type(v) ~= "string" then return v end
|
|
4178
|
+
local ok, res = pcall(plan_lib.json_decode, v)
|
|
4179
|
+
if ok then return res end
|
|
4180
|
+
return nil
|
|
4181
|
+
end
|
|
4182
|
+
|
|
4183
|
+
function ext.mix.run(name, ...)
|
|
4184
|
+
if not ext.mix.ok then
|
|
4185
|
+
return nil, "mcp_mixing_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(ext.mix.lib) .. ")"
|
|
3345
4186
|
end
|
|
4187
|
+
local ok, res, err = pcall(ext.mix.lib[name], reaper, ...)
|
|
4188
|
+
if not ok then return nil, "Handler error: " .. tostring(res) end
|
|
4189
|
+
return res, err
|
|
4190
|
+
end
|
|
3346
4191
|
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
4192
|
+
function ext.mix.tracks(params)
|
|
4193
|
+
params.tracks = ext.mix.decode(params.tracks)
|
|
4194
|
+
return params
|
|
4195
|
+
end
|
|
3351
4196
|
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
4197
|
+
function handlers.set_automation_mode(params)
|
|
4198
|
+
return ext.mix.run("set_automation_mode", plan_lib.resolve_track, ext.mix.tracks(params))
|
|
4199
|
+
end
|
|
4200
|
+
function handlers.get_fx_param_modulation(params) return ext.mix.run("get_modulation", params) end
|
|
4201
|
+
function handlers.set_fx_param_modulation(params)
|
|
4202
|
+
return ext.mix.run("set_modulation", params, {
|
|
4203
|
+
enabled = params.enabled, baseline = params.baseline,
|
|
4204
|
+
lfo = ext.mix.decode(params.lfo), audio = ext.mix.decode(params.audio), link = ext.mix.decode(params.link),
|
|
4205
|
+
})
|
|
3358
4206
|
end
|
|
4207
|
+
function handlers.get_fx_pins(params) return ext.mix.run("get_pins", params) end
|
|
4208
|
+
function handlers.set_fx_pins(params)
|
|
4209
|
+
return ext.mix.run("set_pins", params, ext.mix.decode(params.inputs), ext.mix.decode(params.outputs))
|
|
4210
|
+
end
|
|
4211
|
+
function handlers.list_monitoring_fx() return ext.mix.run("list_monitoring_fx") end
|
|
4212
|
+
function handlers.add_monitoring_fx(params) return ext.mix.run("add_monitoring_fx", params, ext.mix.decode(params.params)) end
|
|
4213
|
+
function handlers.set_monitoring_fx(params) return ext.mix.run("set_monitoring_fx", params, ext.mix.decode(params.params)) end
|
|
4214
|
+
function handlers.remove_monitoring_fx(params) return ext.mix.run("remove_monitoring_fx", params) end
|
|
4215
|
+
function handlers.freeze_tracks(params) return ext.mix.run("freeze_tracks", plan_lib.resolve_track, ext.mix.tracks(params)) end
|
|
4216
|
+
function handlers.unfreeze_tracks(params) return ext.mix.run("unfreeze_tracks", plan_lib.resolve_track, ext.mix.tracks(params)) end
|
|
4217
|
+
function handlers.apply_fx_to_items(params) return ext.mix.run("apply_fx_to_items", params, ext.mix.decode(params.items)) end
|
|
4218
|
+
function handlers.add_stretch_markers(params) return ext.mix.run("add_stretch_markers", params, ext.mix.decode(params.markers)) end
|
|
4219
|
+
function handlers.quantize_audio(params) return ext.mix.run("quantize_stretch", params, ext.mix.decode(params.hits)) end
|
|
4220
|
+
function handlers.edit_stretch_markers(params) return ext.mix.run("edit_stretch_markers", params, ext.mix.decode(params.edits)) end
|
|
4221
|
+
function handlers.align_audio_to_guide(params) return ext.mix.run("align_stretch", params, ext.mix.decode(params.pairs)) end
|
|
3359
4222
|
|
|
3360
4223
|
function handlers.set_envelope_properties(params)
|
|
3361
4224
|
local track = reaper.GetTrack(0, params.trackIndex)
|
|
@@ -3370,12 +4233,18 @@ function handlers.set_envelope_properties(params)
|
|
|
3370
4233
|
|
|
3371
4234
|
if reaper.BR_EnvAlloc then
|
|
3372
4235
|
-- SWS extension available: use BR_Env* functions
|
|
4236
|
+
-- BR_EnvSetProperties needs active, visible, armed, inLane, laneHeight, defaultShape and
|
|
4237
|
+
-- faderScaling; pass the current values back for everything not being changed.
|
|
3373
4238
|
local br_env = reaper.BR_EnvAlloc(env, false)
|
|
3374
|
-
local
|
|
3375
|
-
local
|
|
3376
|
-
local
|
|
3377
|
-
|
|
3378
|
-
|
|
4239
|
+
local cur = { reaper.BR_EnvGetProperties(br_env) }
|
|
4240
|
+
local function pick(v, current) if v == nil then return current end return v end
|
|
4241
|
+
local args = {
|
|
4242
|
+
pick(params.active, cur[1]), pick(params.visible, cur[2]), pick(params.armed, cur[3]),
|
|
4243
|
+
cur[4], cur[5], cur[6], cur[11],
|
|
4244
|
+
}
|
|
4245
|
+
local n = 7
|
|
4246
|
+
if cur[12] ~= nil then n = 8; args[8] = cur[12] end -- automationItemsOptions (newer SWS)
|
|
4247
|
+
reaper.BR_EnvSetProperties(br_env, table.unpack(args, 1, n))
|
|
3379
4248
|
reaper.BR_EnvFree(br_env, true) -- commit=true
|
|
3380
4249
|
else
|
|
3381
4250
|
-- Fallback: modify envelope state chunk directly
|
|
@@ -3419,91 +4288,105 @@ function handlers.set_envelope_properties(params)
|
|
|
3419
4288
|
end
|
|
3420
4289
|
|
|
3421
4290
|
function handlers.clear_envelope(params)
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
local env_count = reaper.CountTrackEnvelopes(track)
|
|
3425
|
-
if params.envelopeIndex >= env_count then
|
|
3426
|
-
return nil, "Envelope index " .. params.envelopeIndex .. " out of range (track has " .. env_count .. " envelopes)"
|
|
3427
|
-
end
|
|
3428
|
-
local env = reaper.GetTrackEnvelope(track, params.envelopeIndex)
|
|
3429
|
-
local prev_count = reaper.CountEnvelopePoints(env)
|
|
3430
|
-
-- Delete all points by using a range from -infinity to +infinity
|
|
3431
|
-
reaper.Undo_BeginBlock()
|
|
3432
|
-
reaper.DeleteEnvelopePointRange(env, -math.huge, math.huge)
|
|
3433
|
-
reaper.Envelope_SortPoints(env)
|
|
3434
|
-
reaper.Undo_EndBlock("MCP: Clear envelope", -1)
|
|
3435
|
-
return {
|
|
3436
|
-
trackIndex = params.trackIndex,
|
|
3437
|
-
envelopeIndex = params.envelopeIndex,
|
|
3438
|
-
deletedPoints = prev_count,
|
|
3439
|
-
totalPoints = reaper.CountEnvelopePoints(env),
|
|
3440
|
-
}
|
|
4291
|
+
return env_ext.run("remove_range", { trackIndex = params.trackIndex, envelopeIndex = params.envelopeIndex,
|
|
4292
|
+
automationItemIndex = params.automationItemIndex }, "MCP: Clear envelope")
|
|
3441
4293
|
end
|
|
3442
4294
|
|
|
3443
4295
|
function handlers.remove_envelope_points(params)
|
|
3444
|
-
|
|
3445
|
-
if not track then return nil, "Track " .. params.trackIndex .. " not found" end
|
|
3446
|
-
local env_count = reaper.CountTrackEnvelopes(track)
|
|
3447
|
-
if params.envelopeIndex >= env_count then
|
|
3448
|
-
return nil, "Envelope index " .. params.envelopeIndex .. " out of range (track has " .. env_count .. " envelopes)"
|
|
3449
|
-
end
|
|
3450
|
-
local env = reaper.GetTrackEnvelope(track, params.envelopeIndex)
|
|
3451
|
-
local prev_count = reaper.CountEnvelopePoints(env)
|
|
3452
|
-
reaper.Undo_BeginBlock()
|
|
3453
|
-
reaper.DeleteEnvelopePointRange(env, params.timeStart, params.timeEnd)
|
|
3454
|
-
reaper.Envelope_SortPoints(env)
|
|
3455
|
-
reaper.Undo_EndBlock("MCP: Remove envelope points", -1)
|
|
3456
|
-
local new_count = reaper.CountEnvelopePoints(env)
|
|
3457
|
-
return {
|
|
3458
|
-
trackIndex = params.trackIndex,
|
|
3459
|
-
envelopeIndex = params.envelopeIndex,
|
|
3460
|
-
timeStart = params.timeStart,
|
|
3461
|
-
timeEnd = params.timeEnd,
|
|
3462
|
-
deletedPoints = prev_count - new_count,
|
|
3463
|
-
totalPoints = new_count,
|
|
3464
|
-
}
|
|
4296
|
+
return env_ext.run("remove_range", params, "MCP: Remove envelope points")
|
|
3465
4297
|
end
|
|
3466
4298
|
|
|
3467
|
-
function handlers.
|
|
3468
|
-
local track = reaper.GetTrack(0, params.trackIndex)
|
|
3469
|
-
if not track then return nil, "Track " .. params.trackIndex .. " not found" end
|
|
3470
|
-
local env_count = reaper.CountTrackEnvelopes(track)
|
|
3471
|
-
if params.envelopeIndex >= env_count then
|
|
3472
|
-
return nil, "Envelope index " .. params.envelopeIndex .. " out of range (track has " .. env_count .. " envelopes)"
|
|
3473
|
-
end
|
|
3474
|
-
local env = reaper.GetTrackEnvelope(track, params.envelopeIndex)
|
|
4299
|
+
function handlers.list_automation_items(params) return env_ext.run("list_items", params) end
|
|
3475
4300
|
|
|
3476
|
-
|
|
3477
|
-
local
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
points
|
|
3481
|
-
elseif type(points_str) == "table" then
|
|
3482
|
-
points = points_str
|
|
4301
|
+
function handlers.insert_automation_item(params)
|
|
4302
|
+
local points
|
|
4303
|
+
if params.points ~= nil then
|
|
4304
|
+
points = env_ext.list(params.points)
|
|
4305
|
+
if not points then return nil, "Failed to parse points JSON" end
|
|
3483
4306
|
end
|
|
3484
|
-
|
|
4307
|
+
return env_ext.run("insert_item", params, points)
|
|
4308
|
+
end
|
|
3485
4309
|
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
4310
|
+
function handlers.set_automation_item(params) return env_ext.run("set_item", params) end
|
|
4311
|
+
|
|
4312
|
+
function handlers.delete_automation_item(params) return env_ext.run("delete_item", params) end
|
|
4313
|
+
|
|
4314
|
+
-- =============================================================================
|
|
4315
|
+
-- MIDI composition: quantize, transform, file import, pitch profile
|
|
4316
|
+
-- =============================================================================
|
|
4317
|
+
|
|
4318
|
+
-- Logic in mcp_midi_lib.lua; one table for the 200-locals limit. Nested params (note
|
|
4319
|
+
-- indexes, the velocity/humanize/length objects, an import's tracks) travel as JSON strings.
|
|
4320
|
+
ext.midi = {}
|
|
4321
|
+
ext.midi.ok, ext.midi.lib = pcall(dofile, script_dir .. "mcp_midi_lib.lua")
|
|
4322
|
+
|
|
4323
|
+
function ext.midi.run(name, params)
|
|
4324
|
+
if not ext.midi.ok then
|
|
4325
|
+
return nil, "mcp_midi_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(ext.midi.lib) .. ")"
|
|
4326
|
+
end
|
|
4327
|
+
local p = {}
|
|
4328
|
+
for k, v in pairs(params) do p[k] = v end
|
|
4329
|
+
for _, k in ipairs({ "notes", "velocity", "humanize", "length", "tracks" }) do
|
|
4330
|
+
if type(p[k]) == "string" then
|
|
4331
|
+
if not plan_lib_ok then return nil, "mcp_plan_lib.lua is not installed — run `reaper-mcp setup`" end
|
|
4332
|
+
local val, err = plan_lib.json_decode(p[k])
|
|
4333
|
+
if type(val) ~= "table" then return nil, k .. " is not valid JSON" .. (err and (" (" .. tostring(err) .. ")") or "") .. " — nothing changed" end
|
|
4334
|
+
p[k] = val
|
|
3494
4335
|
end
|
|
3495
4336
|
end
|
|
4337
|
+
local ok, res, err = pcall(ext.midi.lib[name], reaper, p)
|
|
4338
|
+
if not ok then return nil, "Handler error: " .. tostring(res) end
|
|
4339
|
+
return res, err
|
|
4340
|
+
end
|
|
3496
4341
|
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
4342
|
+
function handlers.quantize_midi(params) return ext.midi.run("quantize", params) end
|
|
4343
|
+
function handlers.transform_midi(params) return ext.midi.run("transform", params) end
|
|
4344
|
+
function handlers.import_midi_file(params) return ext.midi.run("import", params) end
|
|
4345
|
+
-- Internal (detect_key on a MIDI track): not logged, not counted, but refused while paused,
|
|
4346
|
+
-- since it answers an agent's tool call (a read, so read-only mode allows it)
|
|
4347
|
+
handlers["_midi_pitch_profile"] = function(params) return ext.midi.run("pitch_profile", params) end
|
|
4348
|
+
-- Internal, likewise: detect_chords' bar/beat grid (+ MIDI profiles), and an item's range (itemIndex)
|
|
4349
|
+
handlers["_chord_grid"] = function(params) return ext.midi.run("chord_grid", params) end
|
|
4350
|
+
handlers["_item_range"] = function(params) return ext.midi.run("item_range", params) end
|
|
4351
|
+
ext.paused_too = { _midi_pitch_profile = true, _chord_grid = true, _item_range = true }
|
|
4352
|
+
|
|
4353
|
+
-- =============================================================================
|
|
4354
|
+
-- Arrangement: copy items, insert/remove time, copy a section, process items
|
|
4355
|
+
-- =============================================================================
|
|
4356
|
+
|
|
4357
|
+
-- Logic in mcp_arrange_lib.lua (one table, like env_ext: the main chunk is at the locals limit)
|
|
4358
|
+
ext.arrange = {}
|
|
4359
|
+
ext.arrange.ok, ext.arrange.lib = pcall(dofile, script_dir .. "mcp_arrange_lib.lua")
|
|
4360
|
+
plan_ctx.arrange = ext.arrange.ok and ext.arrange.lib or nil
|
|
4361
|
+
|
|
4362
|
+
-- `items` / `tracks` arrive as JSON strings (the bridge decodes only the top level)
|
|
4363
|
+
function ext.arrange.run(name, params)
|
|
4364
|
+
if not ext.arrange.ok then
|
|
4365
|
+
return nil, "mcp_arrange_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(ext.arrange.lib) .. ")"
|
|
4366
|
+
end
|
|
4367
|
+
for _, key in ipairs({ "items", "tracks" }) do
|
|
4368
|
+
if type(params[key]) == "string" then
|
|
4369
|
+
if not plan_lib_ok then return nil, "mcp_plan_lib.lua is not installed next to mcp_bridge.lua" end
|
|
4370
|
+
local ok, v = pcall(plan_lib.json_decode, params[key])
|
|
4371
|
+
if not ok or type(v) ~= "table" then return nil, key .. " is not a JSON array — nothing changed" end
|
|
4372
|
+
params[key] = v
|
|
4373
|
+
end
|
|
4374
|
+
end
|
|
4375
|
+
local ok, res, err = pcall(ext.arrange.lib[name], reaper, params)
|
|
4376
|
+
if not ok then return nil, "Handler error: " .. tostring(res) end
|
|
4377
|
+
return res, err
|
|
3505
4378
|
end
|
|
3506
4379
|
|
|
4380
|
+
function handlers.copy_media_items(params) return ext.arrange.run("copy_items", params) end
|
|
4381
|
+
|
|
4382
|
+
function handlers.insert_time(params) return ext.arrange.run("insert_time", params) end
|
|
4383
|
+
|
|
4384
|
+
function handlers.remove_time(params) return ext.arrange.run("remove_time", params) end
|
|
4385
|
+
|
|
4386
|
+
function handlers.copy_section(params) return ext.arrange.run("copy_section", params) end
|
|
4387
|
+
|
|
4388
|
+
function handlers.process_items(params) return ext.arrange.run("process_items", params) end
|
|
4389
|
+
|
|
3507
4390
|
-- =============================================================================
|
|
3508
4391
|
-- Live MIDI output handlers
|
|
3509
4392
|
-- =============================================================================
|
|
@@ -3617,190 +4500,64 @@ function handlers.send_midi_note(params)
|
|
|
3617
4500
|
end
|
|
3618
4501
|
|
|
3619
4502
|
-- =============================================================================
|
|
3620
|
-
-- render_track_to_wav
|
|
4503
|
+
-- render_track_to_wav / render_tracks_to_wav handlers
|
|
3621
4504
|
--
|
|
3622
|
-
--
|
|
3623
|
-
--
|
|
3624
|
-
--
|
|
4505
|
+
-- Render tracks' output (post-FX, post-fader) to temp WAVs for the analysis tools. The logic is in
|
|
4506
|
+
-- mcp_render_lib.lua: it caches renders until the project changes, renders several tracks in one
|
|
4507
|
+
-- pass and hands focus back after a render (REAPER's progress window takes it). One table, not
|
|
4508
|
+
-- separate locals: the bridge's main chunk is at Lua's 200-locals limit.
|
|
3625
4509
|
--
|
|
3626
|
-
--
|
|
3627
|
-
--
|
|
3628
|
-
--
|
|
3629
|
-
--
|
|
3630
|
-
--
|
|
3631
|
-
--
|
|
3632
|
-
-- Returns:
|
|
3633
|
-
-- { trackIndex, trackName, wavPath, durationSeconds, sampleRate, channelCount }
|
|
4510
|
+
-- render_track_to_wav params: trackIndex (-1 = master), startTime, endTime (seconds), commandId,
|
|
4511
|
+
-- fresh (skip the cache), restoreFocus (false: leave focus where the render put it)
|
|
4512
|
+
-- -> { trackIndex, trackName, wavPath, durationSeconds, sampleRate, channelCount,
|
|
4513
|
+
-- playOffsetMs, phaseInverted, cached, cacheOwned }
|
|
4514
|
+
-- render_tracks_to_wav: the same with trackIndices (JSON array) -> { tracks, rendered, cached }
|
|
3634
4515
|
-- =============================================================================
|
|
3635
4516
|
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
local start_time = params.startTime
|
|
3639
|
-
local end_time = params.endTime
|
|
3640
|
-
local command_id = params.commandId or tostring(os.time())
|
|
3641
|
-
|
|
3642
|
-
-- Validate inputs
|
|
3643
|
-
if track_index == nil then
|
|
3644
|
-
return nil, "Missing required param: trackIndex"
|
|
3645
|
-
end
|
|
3646
|
-
if start_time == nil or end_time == nil then
|
|
3647
|
-
return nil, "Missing required params: startTime, endTime"
|
|
3648
|
-
end
|
|
3649
|
-
if end_time <= start_time then
|
|
3650
|
-
return nil, "endTime must be greater than startTime"
|
|
3651
|
-
end
|
|
4517
|
+
ext.render = {}
|
|
4518
|
+
ext.render.ok, ext.render.lib = pcall(dofile, script_dir .. "mcp_render_lib.lua")
|
|
3652
4519
|
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
return nil, "Track " .. tostring(track_index) .. " not found"
|
|
4520
|
+
function ext.render.run(name, params)
|
|
4521
|
+
if not ext.render.ok then
|
|
4522
|
+
return nil, "mcp_render_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(ext.render.lib) .. ")"
|
|
3657
4523
|
end
|
|
3658
|
-
local
|
|
3659
|
-
if
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
-- The actual project sample rate is irrelevant here; Audiobox resamples internally.
|
|
3663
|
-
local sample_rate = 44100
|
|
3664
|
-
|
|
3665
|
-
-- Build unique temp file path
|
|
3666
|
-
-- Use GetTempPath() if available (REAPER 6.29+), fall back to os.tmpname pattern
|
|
3667
|
-
local temp_dir
|
|
3668
|
-
if reaper.GetTempPath then
|
|
3669
|
-
temp_dir = reaper.GetTempPath()
|
|
3670
|
-
else
|
|
3671
|
-
-- Fallback: derive temp dir from os.tmpname()
|
|
3672
|
-
local t = os.tmpname()
|
|
3673
|
-
temp_dir = t:match("^(.+)[/\\][^/\\]+$") or "/tmp"
|
|
3674
|
-
os.remove(t)
|
|
3675
|
-
end
|
|
3676
|
-
|
|
3677
|
-
-- Ensure temp dir ends with separator
|
|
3678
|
-
if temp_dir:sub(-1) ~= "/" and temp_dir:sub(-1) ~= "\\" then
|
|
3679
|
-
temp_dir = temp_dir .. "/"
|
|
3680
|
-
end
|
|
3681
|
-
|
|
3682
|
-
local wav_filename = "mcp_aes_" .. command_id:gsub("-", ""):sub(1, 16) .. ".wav"
|
|
3683
|
-
local wav_path = temp_dir .. wav_filename
|
|
3684
|
-
|
|
3685
|
-
-- -------------------------------------------------------------------------
|
|
3686
|
-
-- Save current render settings
|
|
3687
|
-
-- -------------------------------------------------------------------------
|
|
3688
|
-
local saved_file = ({reaper.GetSetProjectInfo_String(0, "RENDER_FILE", "", false)})[2] or ""
|
|
3689
|
-
local saved_pattern = ({reaper.GetSetProjectInfo_String(0, "RENDER_PATTERN", "", false)})[2] or ""
|
|
3690
|
-
local saved_format = ({reaper.GetSetProjectInfo_String(0, "RENDER_FORMAT", "", false)})[2] or ""
|
|
3691
|
-
local saved_bounds = reaper.GetSetProjectInfo(0, "RENDER_BOUNDSFLAG", 0, false)
|
|
3692
|
-
local saved_settings = reaper.GetSetProjectInfo(0, "RENDER_SETTINGS", 0, false)
|
|
3693
|
-
local saved_start = reaper.GetSetProjectInfo(0, "RENDER_STARTPOS", 0, false)
|
|
3694
|
-
local saved_end = reaper.GetSetProjectInfo(0, "RENDER_ENDPOS", 0, false)
|
|
3695
|
-
local saved_srate = reaper.GetSetProjectInfo(0, "RENDER_SRATE", 0, false)
|
|
3696
|
-
local saved_channels = reaper.GetSetProjectInfo(0, "RENDER_CHANNELS", 0, false)
|
|
3697
|
-
|
|
3698
|
-
-- Save solo states for all tracks
|
|
3699
|
-
local track_count = reaper.CountTracks(0)
|
|
3700
|
-
local saved_solo_states = {}
|
|
3701
|
-
for i = 0, track_count - 1 do
|
|
3702
|
-
local t = reaper.GetTrack(0, i)
|
|
3703
|
-
saved_solo_states[i] = reaper.GetMediaTrackInfo_Value(t, "I_SOLO")
|
|
3704
|
-
end
|
|
3705
|
-
|
|
3706
|
-
-- -------------------------------------------------------------------------
|
|
3707
|
-
-- Apply render settings
|
|
3708
|
-
-- -------------------------------------------------------------------------
|
|
3709
|
-
local ok, restore_err = pcall(function()
|
|
3710
|
-
-- Set output: render to temp dir with unique filename (no extension in pattern)
|
|
3711
|
-
reaper.GetSetProjectInfo_String(0, "RENDER_FILE", temp_dir, true)
|
|
3712
|
-
reaper.GetSetProjectInfo_String(0, "RENDER_PATTERN", wav_filename:match("^(.+)%.wav$") or wav_filename, true)
|
|
3713
|
-
|
|
3714
|
-
-- Set render bounds: 0 = custom time range
|
|
3715
|
-
reaper.GetSetProjectInfo(0, "RENDER_BOUNDSFLAG", 0, true)
|
|
3716
|
-
reaper.GetSetProjectInfo(0, "RENDER_STARTPOS", start_time, true)
|
|
3717
|
-
reaper.GetSetProjectInfo(0, "RENDER_ENDPOS", end_time, true)
|
|
3718
|
-
|
|
3719
|
-
-- Set render mode: 2 = stems (selected tracks via master mix / solo)
|
|
3720
|
-
reaper.GetSetProjectInfo(0, "RENDER_SETTINGS", 2, true)
|
|
3721
|
-
|
|
3722
|
-
-- Force WAV at 44100 Hz stereo so the sidecar always gets a known format,
|
|
3723
|
-
-- independent of whatever the user last rendered. Restored in finally.
|
|
3724
|
-
-- "evaw" is the little-endian "wave" fourcc REAPER uses for WAV format.
|
|
3725
|
-
reaper.GetSetProjectInfo_String(0, "RENDER_FORMAT", "evaw", true)
|
|
3726
|
-
reaper.GetSetProjectInfo(0, "RENDER_SRATE", sample_rate, true)
|
|
3727
|
-
reaper.GetSetProjectInfo(0, "RENDER_CHANNELS", 2, true)
|
|
3728
|
-
|
|
3729
|
-
-- Unsolo all tracks, then solo only the target track
|
|
3730
|
-
for i = 0, track_count - 1 do
|
|
3731
|
-
local t = reaper.GetTrack(0, i)
|
|
3732
|
-
reaper.SetMediaTrackInfo_Value(t, "I_SOLO", 0)
|
|
3733
|
-
end
|
|
3734
|
-
reaper.SetMediaTrackInfo_Value(track, "I_SOLO", 1)
|
|
3735
|
-
|
|
3736
|
-
-- Trigger render: action 42230 = "Render project, using the most recent render settings, auto-close render dialog"
|
|
3737
|
-
-- This call is synchronous — it blocks the defer loop until rendering completes.
|
|
3738
|
-
reaper.Main_OnCommand(42230, 0)
|
|
3739
|
-
end)
|
|
3740
|
-
|
|
3741
|
-
-- -------------------------------------------------------------------------
|
|
3742
|
-
-- Restore all settings and solo states (always, even on error)
|
|
3743
|
-
-- -------------------------------------------------------------------------
|
|
3744
|
-
local restore_ok, restore_err2 = pcall(function()
|
|
3745
|
-
reaper.GetSetProjectInfo_String(0, "RENDER_FILE", saved_file, true)
|
|
3746
|
-
reaper.GetSetProjectInfo_String(0, "RENDER_PATTERN", saved_pattern, true)
|
|
3747
|
-
reaper.GetSetProjectInfo_String(0, "RENDER_FORMAT", saved_format, true)
|
|
3748
|
-
reaper.GetSetProjectInfo(0, "RENDER_BOUNDSFLAG", saved_bounds, true)
|
|
3749
|
-
reaper.GetSetProjectInfo(0, "RENDER_SETTINGS", saved_settings, true)
|
|
3750
|
-
reaper.GetSetProjectInfo(0, "RENDER_STARTPOS", saved_start, true)
|
|
3751
|
-
reaper.GetSetProjectInfo(0, "RENDER_ENDPOS", saved_end, true)
|
|
3752
|
-
reaper.GetSetProjectInfo(0, "RENDER_SRATE", saved_srate, true)
|
|
3753
|
-
reaper.GetSetProjectInfo(0, "RENDER_CHANNELS", saved_channels, true)
|
|
3754
|
-
for i = 0, track_count - 1 do
|
|
3755
|
-
local t = reaper.GetTrack(0, i)
|
|
3756
|
-
reaper.SetMediaTrackInfo_Value(t, "I_SOLO", saved_solo_states[i] or 0)
|
|
3757
|
-
end
|
|
3758
|
-
end)
|
|
4524
|
+
local ok, res, err = pcall(ext.render.lib[name], reaper, params)
|
|
4525
|
+
if not ok then return nil, "Handler error: " .. tostring(res) end
|
|
4526
|
+
return res, err
|
|
4527
|
+
end
|
|
3759
4528
|
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
end
|
|
4529
|
+
function handlers.render_track_to_wav(params)
|
|
4530
|
+
return ext.render.run("render_track", params)
|
|
4531
|
+
end
|
|
3764
4532
|
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
end
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
if not f then
|
|
3774
|
-
return nil, "Render produced no output file at: " .. wav_path
|
|
3775
|
-
end
|
|
3776
|
-
-- Verify the rendered file is actually a WAV. RENDER_FORMAT was set to "evaw"
|
|
3777
|
-
-- (REAPER's WAV fourcc) above; if REAPER silently ignored that value the file
|
|
3778
|
-
-- could be any codec the user last rendered (MP3/FLAC/etc.). Check the RIFF
|
|
3779
|
-
-- magic bytes so we fail loudly here rather than crashing in the Python sidecar
|
|
3780
|
-
-- with a confusing "Cannot read audio file" error.
|
|
3781
|
-
local magic = f:read(4)
|
|
3782
|
-
f:close()
|
|
3783
|
-
if magic ~= "RIFF" then
|
|
3784
|
-
os.remove(wav_path)
|
|
3785
|
-
return nil, "Render produced a non-WAV file (RENDER_FORMAT may not have been applied). " ..
|
|
3786
|
-
"First 4 bytes: " .. (magic and string.format("%q", magic) or "<empty>")
|
|
4533
|
+
function handlers.render_tracks_to_wav(params)
|
|
4534
|
+
local p = {}
|
|
4535
|
+
for k, v in pairs(params) do p[k] = v end
|
|
4536
|
+
-- Without CF_Json_Parse the command parser hands arrays over as a JSON string
|
|
4537
|
+
if type(params.trackIndices) == "string" then
|
|
4538
|
+
local list = {}
|
|
4539
|
+
for n in params.trackIndices:gmatch("%-?%d+") do list[#list + 1] = tonumber(n) end
|
|
4540
|
+
p.trackIndices = list
|
|
3787
4541
|
end
|
|
3788
|
-
|
|
3789
|
-
local duration = end_time - start_time
|
|
3790
|
-
return {
|
|
3791
|
-
trackIndex = track_index,
|
|
3792
|
-
trackName = track_name,
|
|
3793
|
-
wavPath = wav_path,
|
|
3794
|
-
durationSeconds = duration,
|
|
3795
|
-
sampleRate = sample_rate,
|
|
3796
|
-
channelCount = 2,
|
|
3797
|
-
}
|
|
4542
|
+
return ext.render.run("render_tracks", p)
|
|
3798
4543
|
end
|
|
3799
4544
|
|
|
3800
4545
|
-- =============================================================================
|
|
3801
4546
|
-- Bridge diagnostics handler
|
|
3802
4547
|
-- =============================================================================
|
|
3803
4548
|
|
|
4549
|
+
-- Session ping from each MCP server (see apps/reaper-mcp-server/src/session.ts); the session itself
|
|
4550
|
+
-- is recorded from the command's top-level fields in record_command
|
|
4551
|
+
handlers["_session"] = function(params)
|
|
4552
|
+
return { ok = true }
|
|
4553
|
+
end
|
|
4554
|
+
|
|
4555
|
+
-- A sidecar tool's result for the status page; recorded by record_command (record_tool_result)
|
|
4556
|
+
handlers["_log_result"] = function(params)
|
|
4557
|
+
if type(params.command) ~= "string" then return nil, "command required" end
|
|
4558
|
+
return { logged = true }
|
|
4559
|
+
end
|
|
4560
|
+
|
|
3804
4561
|
handlers["_bridge_diagnostics"] = function(params)
|
|
3805
4562
|
-- Compute defer interval stats from circular buffer
|
|
3806
4563
|
local intervals = {}
|
|
@@ -3843,31 +4600,740 @@ handlers["_bridge_diagnostics"] = function(params)
|
|
|
3843
4600
|
end
|
|
3844
4601
|
|
|
3845
4602
|
-- =============================================================================
|
|
3846
|
-
--
|
|
4603
|
+
-- Status feed (read by the web UI: reaper_www_root/mcp_bridge.html)
|
|
3847
4604
|
-- =============================================================================
|
|
4605
|
+
-- Published as JSON to non-persistent ExtState (in-memory, no disk IO). The UI
|
|
4606
|
+
-- polls it through REAPER's web interface, so all rendering happens in the
|
|
4607
|
+
-- browser, outside REAPER's main thread. Publishing is throttled and only
|
|
4608
|
+
-- happens when something changed (plus a 1s liveness tick from the heartbeat).
|
|
4609
|
+
|
|
4610
|
+
local STATUS_SECTION = "MCPBridge"
|
|
4611
|
+
local STATUS_RECENT_MAX = 50
|
|
4612
|
+
local STATUS_MIN_PUBLISH_INTERVAL = 0.2
|
|
4613
|
+
|
|
4614
|
+
-- os.time() has 1s resolution; anchor time_precise() to it for ms timestamps
|
|
4615
|
+
local epoch_offset = os.time() - reaper.time_precise()
|
|
4616
|
+
local function now_ms()
|
|
4617
|
+
return math.floor((reaper.time_precise() + epoch_offset) * 1000)
|
|
4618
|
+
end
|
|
3848
4619
|
|
|
3849
|
-
local
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
4620
|
+
local status = {
|
|
4621
|
+
startedAt = now_ms(),
|
|
4622
|
+
commands = 0,
|
|
4623
|
+
errors = 0,
|
|
4624
|
+
recent = {}, -- newest first
|
|
4625
|
+
}
|
|
4626
|
+
local status_dirty = true
|
|
4627
|
+
local last_status_publish = 0
|
|
4628
|
+
|
|
4629
|
+
-- Agent sessions (one per MCP server process): commands carry sessionId/sessionDir/sessionClient,
|
|
4630
|
+
-- and each server pings `_session` every 20s so idle agents still show as connected.
|
|
4631
|
+
local SESSIONS_MAX = 20
|
|
4632
|
+
local SESSION_FORGET_S = 3600
|
|
4633
|
+
local sessions = {}
|
|
4634
|
+
|
|
4635
|
+
local function touch_session(cmd, ok, failed)
|
|
4636
|
+
local id = cmd.sessionId
|
|
4637
|
+
if type(id) ~= "string" or id == "" then return nil end
|
|
4638
|
+
local s = sessions[id]
|
|
4639
|
+
local t = now_ms()
|
|
4640
|
+
if not s then
|
|
4641
|
+
s = { id = id, first = t, commands = 0, errors = 0 }
|
|
4642
|
+
sessions[id] = s
|
|
4643
|
+
end
|
|
4644
|
+
s.last = t
|
|
4645
|
+
if type(cmd.sessionDir) == "string" and cmd.sessionDir ~= "" then s.dir = cmd.sessionDir end
|
|
4646
|
+
if type(cmd.sessionClient) == "string" and cmd.sessionClient ~= "" then s.client = cmd.sessionClient end
|
|
4647
|
+
s.ended = nil
|
|
4648
|
+
if ok ~= nil then
|
|
4649
|
+
s.commands = s.commands + 1
|
|
4650
|
+
if failed then s.errors = s.errors + 1 end
|
|
4651
|
+
end
|
|
4652
|
+
status_dirty = true
|
|
4653
|
+
return s
|
|
4654
|
+
end
|
|
3854
4655
|
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
4656
|
+
local function session_list()
|
|
4657
|
+
local list, cutoff = {}, now_ms() - SESSION_FORGET_S * 1000
|
|
4658
|
+
for id, s in pairs(sessions) do
|
|
4659
|
+
if s.last < cutoff then sessions[id] = nil else list[#list + 1] = s end
|
|
4660
|
+
end
|
|
4661
|
+
table.sort(list, function(a, b) return a.last > b.last end)
|
|
4662
|
+
for i = SESSIONS_MAX + 1, #list do
|
|
4663
|
+
sessions[list[i].id] = nil
|
|
4664
|
+
list[i] = nil
|
|
4665
|
+
end
|
|
4666
|
+
return list
|
|
4667
|
+
end
|
|
4668
|
+
|
|
4669
|
+
-- Settings written by the status page through REAPER's web interface (/_/SET/EXTSTATE...).
|
|
4670
|
+
-- cfg_paused is in-memory only (resets with REAPER); the others persist in reaper-extstate.ini.
|
|
4671
|
+
local function cfg(key)
|
|
4672
|
+
return reaper.GetExtState(STATUS_SECTION, key) == "1"
|
|
4673
|
+
end
|
|
4674
|
+
|
|
4675
|
+
-- Read-only mode lets agents inspect and audition but never change the project
|
|
4676
|
+
local READONLY_PREFIXES = { "get_", "list_", "read_", "analyze_", "search_", "measure_" }
|
|
4677
|
+
local READONLY_ALLOW = {
|
|
4678
|
+
snapshot_list = true, render_track_to_wav = true, render_tracks_to_wav = true,
|
|
4679
|
+
play = true, stop = true, set_cursor_position = true, set_time_selection = true,
|
|
4680
|
+
}
|
|
4681
|
+
local function readonly_allows(cmd_type)
|
|
4682
|
+
if READONLY_ALLOW[cmd_type] then return true end
|
|
4683
|
+
for _, p in ipairs(READONLY_PREFIXES) do
|
|
4684
|
+
if cmd_type:sub(1, #p) == p then return true end
|
|
4685
|
+
end
|
|
4686
|
+
return false
|
|
4687
|
+
end
|
|
4688
|
+
|
|
4689
|
+
-- Setup health and staleness, re-checked every 10s (cheap: one file read + a few stats)
|
|
4690
|
+
-- Read at load: inside deferred callbacks get_action_context no longer describes this script's launch
|
|
4691
|
+
local _, script_path, _, script_cmd_id = reaper.get_action_context()
|
|
4692
|
+
local script_source = script_path and read_file(script_path)
|
|
4693
|
+
local HEALTH_INTERVAL = 10
|
|
4694
|
+
local last_health_check = -HEALTH_INTERVAL
|
|
4695
|
+
local health = { sws = false, jsfx = true, outdated = false }
|
|
4696
|
+
local JSFX_FILES = { "mcp_analyzer.jsfx", "mcp_lufs_meter.jsfx", "mcp_correlation_meter.jsfx", "mcp_crest_factor.jsfx", "mcp_midi_emitter.jsfx" }
|
|
4697
|
+
|
|
4698
|
+
local function check_health(now)
|
|
4699
|
+
if now - last_health_check < HEALTH_INTERVAL then return end
|
|
4700
|
+
last_health_check = now
|
|
4701
|
+
local fx_dir = reaper.GetResourcePath() .. "/Effects/reaper-mcp/"
|
|
4702
|
+
local missing = {}
|
|
4703
|
+
for _, f in ipairs(JSFX_FILES) do
|
|
4704
|
+
if not file_exists(fx_dir .. f) then missing[#missing + 1] = f end
|
|
4705
|
+
end
|
|
4706
|
+
local installed = script_path and read_file(script_path)
|
|
4707
|
+
local next_health = {
|
|
4708
|
+
sws = type(reaper.CF_GetSWSVersion) == "function",
|
|
4709
|
+
jsfx = #missing == 0,
|
|
4710
|
+
missingJsfx = #missing > 0 and missing or nil,
|
|
4711
|
+
-- The installed script changed under us (setup ran): this running copy is stale until restarted
|
|
4712
|
+
outdated = script_source ~= nil and installed ~= nil and installed ~= script_source,
|
|
4713
|
+
}
|
|
4714
|
+
if next_health.sws ~= health.sws or next_health.jsfx ~= health.jsfx or next_health.outdated ~= health.outdated then
|
|
4715
|
+
status_dirty = true
|
|
4716
|
+
end
|
|
4717
|
+
health = next_health
|
|
4718
|
+
end
|
|
4719
|
+
|
|
4720
|
+
-- Self-update and restart, requested from the status page with one click.
|
|
4721
|
+
-- The page writes ExtState MCPBridge/request = "<update|restart>-<token>" through REAPER's web interface.
|
|
4722
|
+
-- The token is random per run and only readable from the status feed: REAPER's web server sends no CORS
|
|
4723
|
+
-- headers, so another website can fire a SET at localhost but can't read the token to make it count.
|
|
4724
|
+
-- Updating runs <clone>/scripts/update.sh (git pull --ff-only + install.sh) detached, so REAPER never blocks;
|
|
4725
|
+
-- `reaper-mcp setup` writes mcp_update.conf (clone path + node binary) next to this script.
|
|
4726
|
+
math.randomseed(math.floor(reaper.time_precise() * 1e6) % 2147483647)
|
|
4727
|
+
local control_token = string.format("%08x%08x", math.random(0, 0x7fffffff), math.random(0, 0x7fffffff))
|
|
4728
|
+
local is_windows = (reaper.GetOS and reaper.GetOS() or ""):match("^Win") ~= nil
|
|
4729
|
+
local UPDATE_LOG = bridge_dir .. "update.log"
|
|
4730
|
+
local UPDATE_EXIT = bridge_dir .. "update.exit"
|
|
4731
|
+
local SOURCE_CHANGES = bridge_dir .. "source_changes"
|
|
4732
|
+
local update = nil -- { state = "running" | "failed" | "done", startedAt, log = { lines } }
|
|
4733
|
+
local last_update_poll = 0
|
|
4734
|
+
|
|
4735
|
+
-- REAPER 7.03+: re-running this script stops it and starts the (possibly newer) installed copy
|
|
4736
|
+
local can_restart = type(reaper.set_action_options) == "function" and type(script_cmd_id) == "number" and script_cmd_id > 0
|
|
4737
|
+
-- Running the script again stops it (1: terminate when re-launched); only a restart the bridge asks for itself
|
|
4738
|
+
-- adds 2 (then start again), so a user re-running mcp_bridge.lua turns it off instead of getting a new window
|
|
4739
|
+
if can_restart then reaper.set_action_options(1) end
|
|
4740
|
+
|
|
4741
|
+
local function update_config()
|
|
4742
|
+
local text = read_file(script_dir .. "mcp_update.conf")
|
|
4743
|
+
if not text then return nil end
|
|
4744
|
+
local conf = {}
|
|
4745
|
+
for key, value in text:gmatch("([%w_]+)=([^\r\n]*)") do conf[key] = value end
|
|
4746
|
+
if not conf.repo or conf.repo == "" or not file_exists(conf.repo .. "/scripts/update.sh") then return nil end
|
|
4747
|
+
return conf
|
|
4748
|
+
end
|
|
4749
|
+
|
|
4750
|
+
local function restart_bridge()
|
|
4751
|
+
log_event("bridge_restart", {})
|
|
4752
|
+
reaper.set_action_options(3)
|
|
4753
|
+
reaper.Main_OnCommand(script_cmd_id, 0)
|
|
4754
|
+
end
|
|
4755
|
+
|
|
4756
|
+
local function shell_quote(s)
|
|
4757
|
+
return "'" .. s:gsub("'", "'\\''") .. "'"
|
|
4758
|
+
end
|
|
4759
|
+
|
|
4760
|
+
-- kind "update": pull the latest version and reinstall; "rebuild": developers, build the working tree as it is
|
|
4761
|
+
local function start_update(kind)
|
|
4762
|
+
if is_windows or (update and update.state == "running") then return end
|
|
4763
|
+
local conf = update_config()
|
|
4764
|
+
if not conf then return end
|
|
4765
|
+
os.remove(UPDATE_LOG)
|
|
4766
|
+
os.remove(UPDATE_EXIT)
|
|
4767
|
+
-- REAPER started from the Dock has a bare PATH: put the node that ran setup (and its pnpm/corepack) first
|
|
4768
|
+
local node_dir = conf.node and conf.node:match("^(.*)/[^/]+$")
|
|
4769
|
+
local path = (node_dir and (shell_quote(node_dir) .. ":") or "") .. "/opt/homebrew/bin:/usr/local/bin:$PATH"
|
|
4770
|
+
os.execute("( cd " .. shell_quote(conf.repo) .. " && PATH=" .. path .. " ./scripts/update.sh" .. (kind == "rebuild" and " --rebuild" or "") .. " < /dev/null > " .. shell_quote(UPDATE_LOG)
|
|
4771
|
+
.. " 2>&1; echo $? > " .. shell_quote(UPDATE_EXIT) .. " ) >/dev/null 2>&1 &")
|
|
4772
|
+
update = { kind = kind, state = "running", startedAt = now_ms(), log = {} }
|
|
4773
|
+
log_event("bridge_update_start", { kind = kind })
|
|
4774
|
+
status_dirty = true
|
|
4775
|
+
end
|
|
4776
|
+
|
|
4777
|
+
-- Last lines of the update log, without colors or carriage-return progress redraws
|
|
4778
|
+
local function update_log_tail(max)
|
|
4779
|
+
local text = read_file(UPDATE_LOG) or ""
|
|
4780
|
+
local lines = {}
|
|
4781
|
+
for line in text:gmatch("[^\n]+") do
|
|
4782
|
+
line = line:gsub("\27%[[%d;]*%a", ""):match("([^\r]*)\r*$") or ""
|
|
4783
|
+
if line:match("%S") then lines[#lines + 1] = line end
|
|
4784
|
+
end
|
|
4785
|
+
local tail = {}
|
|
4786
|
+
for i = math.max(1, #lines - max + 1), #lines do tail[#tail + 1] = lines[i] end
|
|
4787
|
+
return tail
|
|
4788
|
+
end
|
|
4789
|
+
|
|
4790
|
+
local function check_control(now)
|
|
4791
|
+
local request = reaper.GetExtState(STATUS_SECTION, "request")
|
|
4792
|
+
if request and request ~= "" then
|
|
4793
|
+
reaper.DeleteExtState(STATUS_SECTION, "request", false)
|
|
4794
|
+
local action, token = request:match("^(%a+)%-(%x+)$")
|
|
4795
|
+
if token == control_token then
|
|
4796
|
+
if action == "update" or action == "rebuild" then start_update(action)
|
|
4797
|
+
elseif action == "restart" and can_restart then restart_bridge() return end
|
|
4798
|
+
end
|
|
4799
|
+
end
|
|
4800
|
+
if not update or update.state ~= "running" or now - last_update_poll < 1 then return end
|
|
4801
|
+
last_update_poll = now
|
|
4802
|
+
local log = update_log_tail(12)
|
|
4803
|
+
if #log ~= #update.log or log[#log] ~= update.log[#update.log] then
|
|
4804
|
+
update.log = log
|
|
4805
|
+
status_dirty = true
|
|
4806
|
+
end
|
|
4807
|
+
local code = read_file(UPDATE_EXIT)
|
|
4808
|
+
if not code then return end
|
|
4809
|
+
code = tonumber(code:match("%d+"))
|
|
4810
|
+
update.state = code == 0 and "done" or "failed"
|
|
4811
|
+
if update.state == "done" then os.remove(SOURCE_CHANGES) end -- re-checked against the new install
|
|
4812
|
+
log_event("bridge_update_end", { exitCode = code })
|
|
4813
|
+
status_dirty = true
|
|
4814
|
+
-- The installer replaced this script: load the new copy (the page reloads when startedAt changes)
|
|
4815
|
+
if update.state == "done" and can_restart then
|
|
4816
|
+
local installed = script_path and read_file(script_path)
|
|
4817
|
+
if installed and installed ~= script_source then restart_bridge() end
|
|
4818
|
+
end
|
|
4819
|
+
end
|
|
4820
|
+
|
|
4821
|
+
-- What the status page may offer: published in the status feed together with the token
|
|
4822
|
+
local can_update, conf_dev, last_can_update_check = false, false, -10
|
|
4823
|
+
local function refresh_update_config()
|
|
4824
|
+
local now = reaper.time_precise()
|
|
4825
|
+
if now - last_can_update_check < 10 then return end
|
|
4826
|
+
last_can_update_check = now
|
|
4827
|
+
local conf = not is_windows and update_config() or nil
|
|
4828
|
+
can_update, conf_dev = conf ~= nil, conf ~= nil and conf.dev == "1"
|
|
4829
|
+
end
|
|
4830
|
+
|
|
4831
|
+
-- Developer mode: the page's toggle (cfg_dev "1"/"0") once chosen, else what setup detected (dev=1 in mcp_update.conf)
|
|
4832
|
+
local function dev_mode()
|
|
4833
|
+
refresh_update_config()
|
|
4834
|
+
local v = reaper.GetExtState(STATUS_SECTION, "cfg_dev")
|
|
4835
|
+
if v == nil or v == "" then return conf_dev end
|
|
4836
|
+
return v == "1"
|
|
4837
|
+
end
|
|
4838
|
+
|
|
4839
|
+
-- Developer mode: whether the clone has source files newer than the last install (setup rewrites
|
|
4840
|
+
-- mcp_update.conf, so its mtime marks it). find runs detached every few seconds and leaves the first
|
|
4841
|
+
-- match in SOURCE_CHANGES; git and the file walk stay off REAPER's main thread.
|
|
4842
|
+
local SOURCE_CHECK_INTERVAL = 5
|
|
4843
|
+
local source_changed, last_source_check = false, -SOURCE_CHECK_INTERVAL
|
|
4844
|
+
local function check_source_changes(now)
|
|
4845
|
+
if is_windows or now - last_source_check < SOURCE_CHECK_INTERVAL or not dev_mode() or not can_update then return end
|
|
4846
|
+
if update and update.state == "running" then return end
|
|
4847
|
+
last_source_check = now
|
|
4848
|
+
local changed = (read_file(SOURCE_CHANGES) or ""):match("%S") ~= nil
|
|
4849
|
+
if changed ~= source_changed then
|
|
4850
|
+
source_changed = changed
|
|
4851
|
+
status_dirty = true
|
|
4852
|
+
end
|
|
4853
|
+
local conf = update_config()
|
|
4854
|
+
if not conf then return end
|
|
4855
|
+
local tmp = shell_quote(SOURCE_CHANGES .. ".tmp")
|
|
4856
|
+
os.execute("( cd " .. shell_quote(conf.repo) .. " && find apps libs reaper knowledge scripts .claude package.json pnpm-lock.yaml"
|
|
4857
|
+
.. " \\( -name node_modules -o -name dist -o -name .nx -o -name __pycache__ -o -name .DS_Store -o -name '*.tsbuildinfo' -o -name '*.timestamp-*' \\) -prune"
|
|
4858
|
+
.. " -o -type f -newer " .. shell_quote(script_dir .. "mcp_update.conf") .. " -print -quit > " .. tmp .. " 2>/dev/null;"
|
|
4859
|
+
.. " mv " .. tmp .. " " .. shell_quote(SOURCE_CHANGES) .. " ) >/dev/null 2>&1 &")
|
|
4860
|
+
end
|
|
4861
|
+
|
|
4862
|
+
local function control_status()
|
|
4863
|
+
refresh_update_config()
|
|
4864
|
+
return {
|
|
4865
|
+
token = control_token,
|
|
4866
|
+
canRestart = can_restart,
|
|
4867
|
+
canUpdate = can_update,
|
|
4868
|
+
-- Developer mode: source changed since the last install, so Rebuild & reload has something to do
|
|
4869
|
+
changed = source_changed,
|
|
4870
|
+
update = update,
|
|
4871
|
+
-- The page may list, restore and save snapshots (mcp_snapshots_ui_lib.lua)
|
|
4872
|
+
snapshots = snaps_lib_ok,
|
|
4873
|
+
-- The page keeps its LoreKit tokens in ~/.config/reaper-mcp (mcp_prefs_lib.lua)
|
|
4874
|
+
sharedTokens = ext.prefs ~= nil,
|
|
4875
|
+
}
|
|
4876
|
+
end
|
|
4877
|
+
|
|
4878
|
+
-- Installed FX for the status page's Plugins view, in ExtState MCPBridge/installed (in memory).
|
|
4879
|
+
-- Re-enumerated every 60s (a few ms for ~2k plugins) and republished only when the list changed.
|
|
4880
|
+
local INSTALLED_FX_INTERVAL = 60
|
|
4881
|
+
local last_installed_fx = -INSTALLED_FX_INTERVAL
|
|
4882
|
+
local installed_fx_signature
|
|
4883
|
+
|
|
4884
|
+
local function publish_installed_fx(now)
|
|
4885
|
+
if now - last_installed_fx < INSTALLED_FX_INTERVAL then return end
|
|
4886
|
+
last_installed_fx = now
|
|
4887
|
+
local list = enumerate_installed_fx()
|
|
4888
|
+
local rows, names = {}, {}
|
|
4889
|
+
for i, fx in ipairs(list or {}) do
|
|
4890
|
+
rows[i] = { n = fx.name, t = fx.type }
|
|
4891
|
+
names[i] = fx.name
|
|
4892
|
+
end
|
|
4893
|
+
local signature = (list and "1" or "0") .. "\n" .. table.concat(names, "\n")
|
|
4894
|
+
if signature == installed_fx_signature then return end
|
|
4895
|
+
installed_fx_signature = signature
|
|
4896
|
+
reaper.SetExtState(STATUS_SECTION, "installed", json_encode({ v = 1, supported = list ~= nil, fx = rows }), false)
|
|
4897
|
+
end
|
|
4898
|
+
|
|
4899
|
+
-- FX used in the open project (track, master, input and take FX), in ExtState MCPBridge/used, so the
|
|
4900
|
+
-- Plugins view can show which plugins this session relies on. Recounted at most every 2s and only when
|
|
4901
|
+
-- the project changed (state change count or project tab); republished only when the counts changed.
|
|
4902
|
+
local USED_FX_INTERVAL = 2
|
|
4903
|
+
local last_used_fx = -USED_FX_INTERVAL
|
|
4904
|
+
local used_fx_state, used_fx_signature
|
|
4905
|
+
|
|
4906
|
+
-- The plugin's own name ("VST3: Pro-Q 3 (FabFilter)"), not a renamed instance
|
|
4907
|
+
local function original_fx_name(get_named, get_name, owner, i)
|
|
4908
|
+
local ok, name = get_named(owner, i, "fx_name")
|
|
4909
|
+
if ok and type(name) == "string" and name ~= "" then return name end
|
|
4910
|
+
local _, display = get_name(owner, i)
|
|
4911
|
+
return type(display) == "string" and display ~= "" and display or nil
|
|
4912
|
+
end
|
|
4913
|
+
|
|
4914
|
+
local function count_project_fx()
|
|
4915
|
+
local counts = {}
|
|
4916
|
+
local function add(name) if name then counts[name] = (counts[name] or 0) + 1 end end
|
|
4917
|
+
local function track_fx(track)
|
|
4918
|
+
if not track then return end
|
|
4919
|
+
for i = 0, (reaper.TrackFX_GetCount(track) or 0) - 1 do
|
|
4920
|
+
add(original_fx_name(reaper.TrackFX_GetNamedConfigParm, reaper.TrackFX_GetFXName, track, i))
|
|
4921
|
+
end
|
|
4922
|
+
for i = 0, (reaper.TrackFX_GetRecCount(track) or 0) - 1 do
|
|
4923
|
+
add(original_fx_name(reaper.TrackFX_GetNamedConfigParm, reaper.TrackFX_GetFXName, track, 0x1000000 + i))
|
|
4924
|
+
end
|
|
4925
|
+
end
|
|
4926
|
+
track_fx(reaper.GetMasterTrack(0))
|
|
4927
|
+
for t = 0, (reaper.CountTracks(0) or 0) - 1 do track_fx(reaper.GetTrack(0, t)) end
|
|
4928
|
+
for it = 0, (reaper.CountMediaItems(0) or 0) - 1 do
|
|
4929
|
+
local item = reaper.GetMediaItem(0, it)
|
|
4930
|
+
for k = 0, (item and reaper.CountTakes(item) or 0) - 1 do
|
|
4931
|
+
local take = reaper.GetTake(item, k)
|
|
4932
|
+
for i = 0, (take and reaper.TakeFX_GetCount(take) or 0) - 1 do
|
|
4933
|
+
add(original_fx_name(reaper.TakeFX_GetNamedConfigParm, reaper.TakeFX_GetFXName, take, i))
|
|
4934
|
+
end
|
|
4935
|
+
end
|
|
4936
|
+
end
|
|
4937
|
+
return counts
|
|
4938
|
+
end
|
|
4939
|
+
|
|
4940
|
+
local function publish_used_fx(now)
|
|
4941
|
+
if now - last_used_fx < USED_FX_INTERVAL then return end
|
|
4942
|
+
last_used_fx = now
|
|
4943
|
+
-- Parenthesised: exactly one value, even from an API stub that returns nothing
|
|
4944
|
+
local change_count = (reaper.GetProjectStateChangeCount(0))
|
|
4945
|
+
local state = tostring((reaper.EnumProjects(-1))) .. ":" .. tostring(change_count)
|
|
4946
|
+
-- An unknown change count (older REAPER, tests) means: recount every time
|
|
4947
|
+
if state == used_fx_state and change_count ~= nil then return end
|
|
4948
|
+
used_fx_state = state
|
|
4949
|
+
local counts = count_project_fx()
|
|
4950
|
+
local names = {}
|
|
4951
|
+
for name in pairs(counts) do names[#names + 1] = name end
|
|
4952
|
+
table.sort(names)
|
|
4953
|
+
local rows, parts = {}, {}
|
|
4954
|
+
for i, name in ipairs(names) do
|
|
4955
|
+
rows[i] = { n = name, c = counts[name] }
|
|
4956
|
+
parts[i] = name .. "=" .. counts[name]
|
|
4957
|
+
end
|
|
4958
|
+
local signature = table.concat(parts, "\n")
|
|
4959
|
+
if signature == used_fx_signature then return end
|
|
4960
|
+
used_fx_signature = signature
|
|
4961
|
+
reaper.SetExtState(STATUS_SECTION, "used", json_encode({ v = 1, fx = rows }), false)
|
|
4962
|
+
end
|
|
4963
|
+
|
|
4964
|
+
-- Command timeline at three resolutions, keyed by absolute bucket id (epoch_ms // size).
|
|
4965
|
+
-- Only buckets with commands exist, so idle time costs nothing.
|
|
4966
|
+
-- Each bucket: { total, errors, slow (ok, >= HISTORY_SLOW_MS, not LONG_RUNNING), summed handler ms }.
|
|
4967
|
+
-- The 10s tier is published live in ExtState `history`; the 1m and 10m tiers (for the page's
|
|
4968
|
+
-- day and week ranges) in `history_long`, at most every 10s. All tiers persist in history.json,
|
|
4969
|
+
-- saved every 30s and on exit, so the timeline survives a bridge or REAPER restart.
|
|
4970
|
+
local HISTORY_TIERS = {
|
|
4971
|
+
{ size = 10000, keep = 3600 * 1000 },
|
|
4972
|
+
{ size = 60000, keep = 24 * 3600 * 1000 },
|
|
4973
|
+
{ size = 600000, keep = 7 * 24 * 3600 * 1000 },
|
|
4974
|
+
}
|
|
4975
|
+
for _, tier in ipairs(HISTORY_TIERS) do tier.b = {} end
|
|
4976
|
+
local HISTORY_SLOW_MS = 100
|
|
4977
|
+
-- Commands whose duration is the work itself (renders, plugin loads, project IO, proxies that run
|
|
4978
|
+
-- arbitrary work): never counted as slow. Mirrors LONG_RUNNING in apps/bridge-ui/src/lib/commands.ts.
|
|
4979
|
+
local LONG_RUNNING = {}
|
|
4980
|
+
for _, c in ipairs({
|
|
4981
|
+
"render_project", "render_track_to_wav", "render_tracks_to_wav", "render_versions", "run_plan", "run_action", "save_project", "undo_project",
|
|
4982
|
+
"redo_project", "add_fx", "add_take_fx", "setup_fx_chain", "copy_fx", "move_fx", "set_fx_offline",
|
|
4983
|
+
"set_fx_preset", "get_fx_preset_list", "list_available_fx", "load_fx_chain", "insert_track_template",
|
|
4984
|
+
"insert_audio_file", "snapshot_save", "snapshot_restore", "snapshot_edit",
|
|
4985
|
+
"analyze_track_offline", "analyze_audio_file", "measure_track_alignment", "verify_edit", "analyze_track_aesthetics",
|
|
4986
|
+
"detect_transients", "create_transient_automation", "insert_stem_tracks",
|
|
4987
|
+
"analyze_loudness_curve", "analyze_stereo_bands", "measure_masking", "separate_stems",
|
|
4988
|
+
"transcribe_voice_sketch",
|
|
4989
|
+
"freeze_tracks", "unfreeze_tracks", "apply_fx_to_items", "quantize_audio", "align_audio_to_guide", "detect_key", "detect_chords", "import_midi_file", "process_items",
|
|
4990
|
+
}) do LONG_RUNNING[c] = true end
|
|
4991
|
+
local HISTORY_MIN_PUBLISH_INTERVAL = 1.0
|
|
4992
|
+
local HISTORY_LONG_PUBLISH_INTERVAL = 10
|
|
4993
|
+
local HISTORY_SAVE_INTERVAL = 30
|
|
4994
|
+
local HISTORY_FILE = bridge_dir .. "history.json"
|
|
4995
|
+
local history_dirty, history_long_dirty, history_unsaved = false, false, false
|
|
4996
|
+
-- Last command sequence number, saved with the timeline: the log alone forgets it after a week idle
|
|
4997
|
+
local saved_seq = 0
|
|
4998
|
+
local last_history_publish, last_history_long_publish, last_history_save =
|
|
4999
|
+
-HISTORY_MIN_PUBLISH_INTERVAL, -HISTORY_LONG_PUBLISH_INTERVAL, 0
|
|
5000
|
+
|
|
5001
|
+
local function record_history(handler_ms, ok, cmd_type, failed)
|
|
5002
|
+
local slow = ok and handler_ms >= HISTORY_SLOW_MS and not LONG_RUNNING[cmd_type]
|
|
5003
|
+
local t = now_ms()
|
|
5004
|
+
for _, tier in ipairs(HISTORY_TIERS) do
|
|
5005
|
+
local id = math.floor(t / tier.size)
|
|
5006
|
+
local b = tier.b[id]
|
|
5007
|
+
if not b then
|
|
5008
|
+
b = { 0, 0, 0, 0 }
|
|
5009
|
+
tier.b[id] = b
|
|
5010
|
+
end
|
|
5011
|
+
b[1] = b[1] + 1
|
|
5012
|
+
if failed then b[2] = b[2] + 1 end
|
|
5013
|
+
if slow then b[3] = b[3] + 1 end -- slow successes; errors counted apart
|
|
5014
|
+
b[4] = b[4] + handler_ms
|
|
5015
|
+
end
|
|
5016
|
+
history_dirty, history_long_dirty, history_unsaved = true, true, true
|
|
5017
|
+
end
|
|
5018
|
+
|
|
5019
|
+
-- Rows `[bucketId, total, errors, slow, sumMs]`, oldest first; drops buckets older than the tier keeps
|
|
5020
|
+
local function tier_rows(tier)
|
|
5021
|
+
local oldest = math.floor(now_ms() / tier.size) - tier.keep // tier.size + 1
|
|
5022
|
+
local ids = {}
|
|
5023
|
+
for id in pairs(tier.b) do
|
|
5024
|
+
if id < oldest then tier.b[id] = nil else ids[#ids + 1] = id end
|
|
5025
|
+
end
|
|
5026
|
+
table.sort(ids)
|
|
5027
|
+
local rows = {}
|
|
5028
|
+
for i, id in ipairs(ids) do
|
|
5029
|
+
local b = tier.b[id]
|
|
5030
|
+
rows[i] = { id, b[1], b[2], b[3], math.floor(b[4] * 10 + 0.5) / 10 }
|
|
5031
|
+
end
|
|
5032
|
+
return rows
|
|
5033
|
+
end
|
|
5034
|
+
|
|
5035
|
+
local function publish_history(now)
|
|
5036
|
+
if history_dirty and now - last_history_publish >= HISTORY_MIN_PUBLISH_INTERVAL then
|
|
5037
|
+
last_history_publish = now
|
|
5038
|
+
history_dirty = false
|
|
5039
|
+
-- b: [bucketId, total, errors, slow, sumMs]; bucket start = bucketId * size
|
|
5040
|
+
local tier = HISTORY_TIERS[1]
|
|
5041
|
+
reaper.SetExtState(STATUS_SECTION, "history", json_encode({ v = 1, size = tier.size, b = tier_rows(tier) }), false)
|
|
5042
|
+
end
|
|
5043
|
+
if history_long_dirty and now - last_history_long_publish >= HISTORY_LONG_PUBLISH_INTERVAL then
|
|
5044
|
+
last_history_long_publish = now
|
|
5045
|
+
history_long_dirty = false
|
|
5046
|
+
local tiers = {}
|
|
5047
|
+
for i = 2, #HISTORY_TIERS do
|
|
5048
|
+
tiers[#tiers + 1] = { size = HISTORY_TIERS[i].size, b = tier_rows(HISTORY_TIERS[i]) }
|
|
5049
|
+
end
|
|
5050
|
+
reaper.SetExtState(STATUS_SECTION, "history_long", json_encode({ v = 1, tiers = tiers }), false)
|
|
5051
|
+
end
|
|
5052
|
+
end
|
|
5053
|
+
|
|
5054
|
+
local function save_history(now, force)
|
|
5055
|
+
if not history_unsaved or (not force and now - last_history_save < HISTORY_SAVE_INTERVAL) then return end
|
|
5056
|
+
last_history_save = now
|
|
5057
|
+
history_unsaved = false
|
|
5058
|
+
local tiers = {}
|
|
5059
|
+
for i, tier in ipairs(HISTORY_TIERS) do tiers[i] = { size = tier.size, b = tier_rows(tier) } end
|
|
5060
|
+
write_file(HISTORY_FILE, json_encode({ v = 1, seq = status.seq, tiers = tiers }))
|
|
5061
|
+
end
|
|
5062
|
+
|
|
5063
|
+
local function load_history()
|
|
5064
|
+
local content = read_file(HISTORY_FILE)
|
|
5065
|
+
if not content or not plan_lib_ok then return end
|
|
5066
|
+
local ok, saved = pcall(plan_lib.json_decode, content)
|
|
5067
|
+
if not ok or type(saved) ~= "table" or type(saved.tiers) ~= "table" then return end
|
|
5068
|
+
if type(saved.seq) == "number" then saved_seq = saved.seq end
|
|
5069
|
+
for _, st in ipairs(saved.tiers) do
|
|
5070
|
+
for _, tier in ipairs(HISTORY_TIERS) do
|
|
5071
|
+
if type(st) == "table" and st.size == tier.size and type(st.b) == "table" then
|
|
5072
|
+
for _, r in ipairs(st.b) do
|
|
5073
|
+
if type(r) == "table" and #r >= 5 then tier.b[r[1]] = { r[2], r[3], r[4], r[5] } end
|
|
5074
|
+
end
|
|
5075
|
+
end
|
|
5076
|
+
end
|
|
5077
|
+
end
|
|
5078
|
+
for _, tier in ipairs(HISTORY_TIERS) do tier_rows(tier) end -- drop what aged out while we were stopped
|
|
5079
|
+
history_dirty, history_long_dirty = true, true
|
|
5080
|
+
end
|
|
5081
|
+
|
|
5082
|
+
-- Command log: every logged command is appended to history_log.jsonl (like bridge_events.jsonl)
|
|
5083
|
+
-- and reloaded on start, so the page's log survives a restart. Kept to the newest LOG_KEEP_MAX
|
|
5084
|
+
-- entries within the week the timeline covers; the file is compacted on start and whenever it
|
|
5085
|
+
-- has grown by LOG_KEEP_MAX lines. Published as ExtState `log` (newest first) only when the page
|
|
5086
|
+
-- asks by writing ExtState `log_want` (it does on open, after a restart and on a gap): encoding a
|
|
5087
|
+
-- thousand entries on every change would cost the main thread for a key that is rarely read.
|
|
5088
|
+
local LOG_FILE = bridge_dir .. "history_log.jsonl"
|
|
5089
|
+
local LOG_KEEP_MAX = 1000
|
|
5090
|
+
local LOG_KEEP_MS = HISTORY_TIERS[#HISTORY_TIERS].keep
|
|
5091
|
+
local saved_log = {} -- newest first
|
|
5092
|
+
local log_appends = 0
|
|
5093
|
+
local last_log_request = ""
|
|
5094
|
+
|
|
5095
|
+
local function compact_log()
|
|
5096
|
+
local lines = {}
|
|
5097
|
+
for i = #saved_log, 1, -1 do lines[#lines + 1] = json_encode(saved_log[i]) end
|
|
5098
|
+
write_file(LOG_FILE, #lines > 0 and table.concat(lines, "\n") .. "\n" or "")
|
|
5099
|
+
log_appends = 0
|
|
5100
|
+
end
|
|
5101
|
+
|
|
5102
|
+
local function load_log()
|
|
5103
|
+
local content = read_file(LOG_FILE)
|
|
5104
|
+
if not content or not plan_lib_ok then return end
|
|
5105
|
+
local cutoff = now_ms() - LOG_KEEP_MS
|
|
5106
|
+
local entries = {} -- oldest first, as written
|
|
5107
|
+
for line in content:gmatch("[^\n]+") do
|
|
5108
|
+
local ok, e = pcall(plan_lib.json_decode, line)
|
|
5109
|
+
if ok and type(e) == "table" and type(e.n) == "number" and type(e.t) == "number" and e.t >= cutoff then
|
|
5110
|
+
entries[#entries + 1] = e
|
|
5111
|
+
end
|
|
5112
|
+
end
|
|
5113
|
+
for i = #entries, math.max(1, #entries - LOG_KEEP_MAX + 1), -1 do saved_log[#saved_log + 1] = entries[i] end
|
|
5114
|
+
compact_log()
|
|
5115
|
+
end
|
|
5116
|
+
|
|
5117
|
+
local function append_log(entry)
|
|
5118
|
+
table.insert(saved_log, 1, entry)
|
|
5119
|
+
saved_log[LOG_KEEP_MAX + 1] = nil
|
|
5120
|
+
log_appends = log_appends + 1
|
|
5121
|
+
if log_appends >= LOG_KEEP_MAX then return compact_log() end
|
|
5122
|
+
local f = io.open(LOG_FILE, "a")
|
|
5123
|
+
if not f then return end
|
|
5124
|
+
f:write(json_encode(entry) .. "\n")
|
|
5125
|
+
f:close()
|
|
5126
|
+
end
|
|
5127
|
+
|
|
5128
|
+
local function publish_log()
|
|
5129
|
+
local req = reaper.GetExtState(STATUS_SECTION, "log_want")
|
|
5130
|
+
if req == "" or req == last_log_request then return end
|
|
5131
|
+
last_log_request = req
|
|
5132
|
+
-- req echoes the page's request so it can tell this answer from an older one
|
|
5133
|
+
reaper.SetExtState(STATUS_SECTION, "log", json_encode({ v = 1, req = req, entries = saved_log }), false)
|
|
5134
|
+
end
|
|
5135
|
+
|
|
5136
|
+
load_history()
|
|
5137
|
+
load_log()
|
|
5138
|
+
-- Sequence numbers continue across restarts so the page can merge the saved log with live entries
|
|
5139
|
+
status.seq = math.max(saved_seq, saved_log[1] and saved_log[1].n or 0)
|
|
5140
|
+
for i = 1, math.min(STATUS_RECENT_MAX, #saved_log) do status.recent[i] = saved_log[i] end
|
|
5141
|
+
|
|
5142
|
+
local STATUS_PARAMS_MAX = 400
|
|
5143
|
+
|
|
5144
|
+
-- Measurement results and edit views are kept on the log entry (`r`) so the status page can draw them
|
|
5145
|
+
-- (mcp_edit_views_lib.lua); everything else logs params only.
|
|
5146
|
+
if edit_lib_ok then
|
|
5147
|
+
edit_lib.init({
|
|
5148
|
+
api = reaper, to_db = to_db, json_decode = plan_lib_ok and plan_lib.json_decode or nil, json_decode_array = json_decode_array,
|
|
5149
|
+
get_midi_notes = function(p) return handlers.get_midi_notes(p) end, get_midi_take = get_midi_take,
|
|
5150
|
+
get_midi_cc = function(p) return handlers.get_midi_cc(p) end,
|
|
5151
|
+
recording = not ext.recording.load_error and ext.recording or nil,
|
|
5152
|
+
resolve_track = function(ref)
|
|
5153
|
+
local res = plan_lib_ok and plan_lib.resolve_track(reaper, ref, {}) or nil
|
|
5154
|
+
return res and res.track or nil
|
|
5155
|
+
end,
|
|
5156
|
+
})
|
|
5157
|
+
end
|
|
5158
|
+
|
|
5159
|
+
-- Results of tools the server composes itself (sidecar analysis), sent as `_log_result` so the page shows them
|
|
5160
|
+
-- like any command: c = the tool, its own duration, and `r` (a JSON string) for the measurement views.
|
|
5161
|
+
local STATUS_RESULT_MAX = 6000
|
|
5162
|
+
local function record_tool_result(cmd, session)
|
|
5163
|
+
local p = cmd.params
|
|
5164
|
+
local entry = {
|
|
5165
|
+
n = status.seq + 1, t = now_ms(), c = p.command:sub(1, 64), ms = math.floor((tonumber(p.ms) or 0) * 100 + 0.5) / 100,
|
|
5166
|
+
ok = p.ok ~= 0 and p.ok ~= false,
|
|
5167
|
+
}
|
|
5168
|
+
if session then entry.s = session.id end
|
|
5169
|
+
if type(p.p) == "string" and p.p ~= "" and p.p ~= "{}" then entry.p = p.p:sub(1, STATUS_PARAMS_MAX) end
|
|
5170
|
+
if type(p.e) == "string" then entry.e = p.e:gsub("%c", " "):sub(1, 300) end
|
|
5171
|
+
if type(p.r) == "string" and #p.r <= STATUS_RESULT_MAX and plan_lib_ok then
|
|
5172
|
+
local got, r = pcall(plan_lib.json_decode, p.r)
|
|
5173
|
+
if got and type(r) == "table" then entry.r = r end
|
|
5174
|
+
end
|
|
5175
|
+
status.commands = status.commands + 1
|
|
5176
|
+
status.seq = status.seq + 1
|
|
5177
|
+
table.insert(status.recent, 1, entry)
|
|
5178
|
+
status.recent[STATUS_RECENT_MAX + 1] = nil
|
|
5179
|
+
status_dirty = true
|
|
5180
|
+
record_history(entry.ms, entry.ok, entry.c, false)
|
|
5181
|
+
append_log(entry)
|
|
5182
|
+
end
|
|
5183
|
+
|
|
5184
|
+
local function record_command(cmd, handler_ms, ok, err, data, before)
|
|
5185
|
+
local cmd_type = cmd.type
|
|
5186
|
+
-- Internal commands (e.g. the server's _bridge_diagnostics poller) are noise, but still mark the session alive
|
|
5187
|
+
if type(cmd_type) ~= "string" or cmd_type:sub(1, 1) == "_" then
|
|
5188
|
+
local s = touch_session(cmd, nil)
|
|
5189
|
+
if s and cmd_type == "_session" and type(cmd.params) == "table" and cmd.params.ended then s.ended = true end
|
|
5190
|
+
if cmd_type == "_log_result" and ok and type(cmd.params) == "table" and type(cmd.params.command) == "string" then
|
|
5191
|
+
record_tool_result(cmd, s)
|
|
5192
|
+
end
|
|
5193
|
+
return
|
|
5194
|
+
end
|
|
5195
|
+
-- Only system errors count as errors. A refused request ("Track 9 not found") is a
|
|
5196
|
+
-- warning: the caller asked for something that can't be done. Same prefixes as
|
|
5197
|
+
-- classifyBridgeError() in bridge.ts and SYSTEM_ERROR_PREFIXES in the page's lib/commands.ts.
|
|
5198
|
+
local e_str = err and tostring(err) or ""
|
|
5199
|
+
local failed = not ok and (e_str:find("^Handler error:") or e_str:find("^Unknown command type")
|
|
5200
|
+
or e_str:find("^Internal error:")) ~= nil
|
|
5201
|
+
local session = touch_session(cmd, ok, failed)
|
|
5202
|
+
status.commands = status.commands + 1
|
|
5203
|
+
status.seq = status.seq + 1
|
|
5204
|
+
if failed then status.errors = status.errors + 1 end
|
|
5205
|
+
local entry = { n = status.seq, t = now_ms(), c = cmd_type, ms = math.floor(handler_ms * 100 + 0.5) / 100, ok = ok }
|
|
5206
|
+
if session then entry.s = session.id end
|
|
5207
|
+
if type(cmd.params) == "table" and next(cmd.params) ~= nil then
|
|
5208
|
+
-- Decode arrays the flat command parser left as JSON text, as the handlers do, so they log as JSON
|
|
5209
|
+
local logged = {}
|
|
5210
|
+
for k, v in pairs(cmd.params) do
|
|
5211
|
+
logged[k] = type(v) == "string" and v:find("^%s*[%[{]") and decode_list(v, k) or v
|
|
5212
|
+
end
|
|
5213
|
+
entry.p = json_encode(logged):sub(1, STATUS_PARAMS_MAX)
|
|
5214
|
+
end
|
|
5215
|
+
if err then
|
|
5216
|
+
entry.e = tostring(err):gsub("%c", " "):sub(1, 300)
|
|
5217
|
+
end
|
|
5218
|
+
-- Sent from the status page (a snapshot restore or save), not by an agent
|
|
5219
|
+
if cmd.source == "page" then entry.src = "page" end
|
|
5220
|
+
if ok and edit_lib_ok then
|
|
5221
|
+
local got, r = pcall(edit_lib.summary, cmd_type, cmd.params or {}, data, before)
|
|
5222
|
+
entry.r = got and r or nil
|
|
5223
|
+
end
|
|
5224
|
+
table.insert(status.recent, 1, entry)
|
|
5225
|
+
status.recent[STATUS_RECENT_MAX + 1] = nil
|
|
5226
|
+
status_dirty = true
|
|
5227
|
+
record_history(handler_ms, ok, cmd_type, failed)
|
|
5228
|
+
append_log(entry)
|
|
5229
|
+
end
|
|
5230
|
+
|
|
5231
|
+
-- cfg_open_on_start ("1"/"0") overrides OPEN_UI_ON_START once the user has chosen in the page
|
|
5232
|
+
local function open_on_start()
|
|
5233
|
+
local v = reaper.GetExtState(STATUS_SECTION, "cfg_open_on_start")
|
|
5234
|
+
if v == "" then return OPEN_UI_ON_START end
|
|
5235
|
+
return v == "1"
|
|
5236
|
+
end
|
|
5237
|
+
|
|
5238
|
+
-- REAPER loads only csurf_0 … csurf_<cnt-1>; an HTTP line past the count is a leftover it ignores
|
|
5239
|
+
local function find_web_port()
|
|
5240
|
+
local ini = read_file(reaper.get_ini_file())
|
|
5241
|
+
if not ini then return nil end
|
|
5242
|
+
local active = tonumber(ini:match("\ncsurf_cnt=(%d+)")) or 0
|
|
5243
|
+
for i, port in ini:gmatch("\ncsurf_(%d+)=HTTP %S+ (%d+)") do
|
|
5244
|
+
if tonumber(i) < active then return port end
|
|
5245
|
+
end
|
|
5246
|
+
end
|
|
5247
|
+
|
|
5248
|
+
-- REAPER's web server never frees a socket whose client hung up first: it stays in CLOSE_WAIT or CLOSED, and
|
|
5249
|
+
-- at about 100 the server stops answering until REAPER restarts. Count them every 30 s so the page can warn
|
|
5250
|
+
-- before that, with lsof (~50 ms) in the background so the defer loop never waits. macOS and Linux only.
|
|
5251
|
+
local web_watch = { interval = 30, limit = 100, last = -30, file = bridge_dir .. "web_sockets" }
|
|
5252
|
+
os.remove(web_watch.file) -- a count left by an earlier REAPER run describes a server that is gone
|
|
5253
|
+
|
|
5254
|
+
function web_watch.check(now)
|
|
5255
|
+
if is_windows or now - web_watch.last < web_watch.interval then return end
|
|
5256
|
+
web_watch.last = now
|
|
5257
|
+
web_watch.port = web_watch.port or find_web_port()
|
|
5258
|
+
if not web_watch.port then return end
|
|
5259
|
+
local stuck = tonumber((read_file(web_watch.file) or ""):match("%d+"))
|
|
5260
|
+
if stuck and stuck ~= (web_watch.status and web_watch.status.stuck) then
|
|
5261
|
+
web_watch.status = { stuck = stuck, limit = web_watch.limit }
|
|
5262
|
+
status_dirty = true
|
|
5263
|
+
if stuck >= web_watch.limit * 0.9 and not web_watch.warned then
|
|
5264
|
+
web_watch.warned = true
|
|
5265
|
+
reaper.ShowConsoleMsg(string.format("MCP Bridge: REAPER's web server holds %d of about %d connections it never "
|
|
5266
|
+
.. "freed, so the status page will stop loading. Restart REAPER to clear them.\n", stuck, web_watch.limit))
|
|
5267
|
+
end
|
|
5268
|
+
end
|
|
5269
|
+
local tmp = shell_quote(web_watch.file .. ".tmp")
|
|
5270
|
+
os.execute("(lsof -nP -iTCP:" .. web_watch.port .. " -sTCP:CLOSE_WAIT,CLOSED 2>/dev/null"
|
|
5271
|
+
.. " | awk 'NR > 1 && tolower($1) == \"reaper\"' | wc -l > " .. tmp
|
|
5272
|
+
.. " && mv " .. tmp .. " " .. shell_quote(web_watch.file) .. ") >/dev/null 2>&1 &")
|
|
5273
|
+
end
|
|
5274
|
+
|
|
5275
|
+
local function publish_status(now, force)
|
|
5276
|
+
if not force and (not status_dirty or now - last_status_publish < STATUS_MIN_PUBLISH_INTERVAL) then
|
|
3859
5277
|
return
|
|
3860
5278
|
end
|
|
5279
|
+
last_status_publish = now
|
|
5280
|
+
status_dirty = false
|
|
5281
|
+
|
|
5282
|
+
local defer_sum, defer_n = 0, math.min(defer_count, defer_buf_size)
|
|
5283
|
+
for i = 1, defer_n do defer_sum = defer_sum + defer_intervals[i] end
|
|
3861
5284
|
|
|
5285
|
+
reaper.SetExtState(STATUS_SECTION, "status", json_encode({
|
|
5286
|
+
v = 1,
|
|
5287
|
+
ts = now_ms(),
|
|
5288
|
+
startedAt = status.startedAt,
|
|
5289
|
+
reaperVersion = reaper.GetAppVersion(),
|
|
5290
|
+
project = select(2, reaper.EnumProjects(-1)) or "",
|
|
5291
|
+
bridgeDir = bridge_dir,
|
|
5292
|
+
commands = status.commands,
|
|
5293
|
+
errors = status.errors,
|
|
5294
|
+
deferMs = defer_n > 0 and math.floor(defer_sum / defer_n * 100000 + 0.5) / 100 or 0,
|
|
5295
|
+
recent = status.recent,
|
|
5296
|
+
sessions = session_list(),
|
|
5297
|
+
settings = { paused = cfg("cfg_paused"), readonly = cfg("cfg_readonly"), openOnStart = open_on_start(), dev = dev_mode() },
|
|
5298
|
+
health = health,
|
|
5299
|
+
web = web_watch.status,
|
|
5300
|
+
control = control_status(),
|
|
5301
|
+
}), false)
|
|
5302
|
+
end
|
|
5303
|
+
|
|
5304
|
+
-- =============================================================================
|
|
5305
|
+
-- Command dispatcher
|
|
5306
|
+
-- =============================================================================
|
|
5307
|
+
|
|
5308
|
+
-- Runs one command (from a command file, or a status-page request) and logs it; returns the response
|
|
5309
|
+
local function run_command(cmd)
|
|
3862
5310
|
-- Dispatch to handler
|
|
3863
5311
|
local handler = handlers[cmd.type]
|
|
3864
5312
|
local response = {}
|
|
3865
5313
|
local handler_ms = 0
|
|
3866
|
-
|
|
3867
|
-
|
|
5314
|
+
local before -- what a write is about to change, for its edit view
|
|
5315
|
+
|
|
5316
|
+
local internal = type(cmd.type) == "string" and cmd.type:sub(1, 1) == "_"
|
|
5317
|
+
local blocked
|
|
5318
|
+
if (not internal or ext.paused_too[cmd.type]) and cfg("cfg_paused") then
|
|
5319
|
+
blocked = "Paused by user in the MCP Bridge window. Ask the user to resume agent control."
|
|
5320
|
+
elseif not internal and cfg("cfg_readonly") and not readonly_allows(cmd.type) then
|
|
5321
|
+
blocked = "Read-only mode is on in the MCP Bridge window: '" .. tostring(cmd.type)
|
|
5322
|
+
.. "' would change the project. Ask the user to turn read-only off."
|
|
5323
|
+
end
|
|
5324
|
+
|
|
5325
|
+
if blocked then
|
|
5326
|
+
response = { id = cmd.id, success = false, error = blocked, timestamp = os.time() * 1000 }
|
|
5327
|
+
elseif handler then
|
|
5328
|
+
if edit_lib_ok then
|
|
5329
|
+
local got, snapshot = pcall(edit_lib.before, cmd.type, cmd.params or {})
|
|
5330
|
+
before = got and snapshot or nil
|
|
5331
|
+
end
|
|
3868
5332
|
local handler_start = reaper.time_precise()
|
|
3869
5333
|
local ok, data_or_err, err_msg = pcall(handler, cmd.params or {})
|
|
3870
5334
|
handler_ms = (reaper.time_precise() - handler_start) * 1000
|
|
5335
|
+
-- A command that may have changed the project makes every cached render stale
|
|
5336
|
+
if ext.render.ok and not internal and not readonly_allows(cmd.type) then ext.render.lib.note_write() end
|
|
3871
5337
|
|
|
3872
5338
|
if not ok then
|
|
3873
5339
|
-- pcall caught a Lua runtime error — handler crashed
|
|
@@ -3894,6 +5360,26 @@ local function process_command(filename)
|
|
|
3894
5360
|
-- Add handler timing to response (rounded to microsecond precision)
|
|
3895
5361
|
response._handlerMs = math.floor(handler_ms * 1000 + 0.5) / 1000
|
|
3896
5362
|
|
|
5363
|
+
record_command(cmd, handler_ms, response.success, response.error, response.data, before)
|
|
5364
|
+
if snaps_lib_ok then snaps_lib.after_command(cmd, response) end
|
|
5365
|
+
return response
|
|
5366
|
+
end
|
|
5367
|
+
|
|
5368
|
+
local function process_command(filename)
|
|
5369
|
+
local pickup_time = reaper.time_precise()
|
|
5370
|
+
local path = bridge_dir .. filename
|
|
5371
|
+
local content = read_file(path)
|
|
5372
|
+
if not content then return end
|
|
5373
|
+
|
|
5374
|
+
local cmd = json_decode(content)
|
|
5375
|
+
if not cmd or not cmd.id or not cmd.type then
|
|
5376
|
+
-- Invalid command, remove file
|
|
5377
|
+
os.remove(path)
|
|
5378
|
+
return
|
|
5379
|
+
end
|
|
5380
|
+
|
|
5381
|
+
local response = run_command(cmd)
|
|
5382
|
+
|
|
3897
5383
|
-- Add pickup timing to response for profiling
|
|
3898
5384
|
if cmd.timestamp then
|
|
3899
5385
|
response._pickupMs = math.floor((pickup_time - (cmd.timestamp / 1000)) * 1000 + 0.5)
|
|
@@ -3909,6 +5395,27 @@ local function process_command(filename)
|
|
|
3909
5395
|
os.remove(path)
|
|
3910
5396
|
end
|
|
3911
5397
|
|
|
5398
|
+
if snaps_lib_ok then
|
|
5399
|
+
snaps_lib.init({
|
|
5400
|
+
api = reaper, section = STATUS_SECTION, dir = get_snapshot_dir, storage = get_snapshot_storage_location,
|
|
5401
|
+
decode = plan_lib_ok and plan_lib.json_decode or json_decode, encode = json_encode, read_file = read_file,
|
|
5402
|
+
token = function() return control_token end, run = run_command,
|
|
5403
|
+
})
|
|
5404
|
+
snaps_lib.register_actions(reaper, script_dir, read_kb_ini())
|
|
5405
|
+
end
|
|
5406
|
+
|
|
5407
|
+
if ext.prefs then
|
|
5408
|
+
local windows = reaper.GetOS and tostring(reaper.GetOS()):find("Win") ~= nil
|
|
5409
|
+
ext.prefs.init({
|
|
5410
|
+
api = reaper, section = STATUS_SECTION, dir = ext.prefs.default_dir(os.getenv, windows),
|
|
5411
|
+
decode = plan_lib_ok and plan_lib.json_decode or json_decode, encode = json_encode, read_file = read_file,
|
|
5412
|
+
token = function() return control_token end,
|
|
5413
|
+
chmod = not windows and function(path)
|
|
5414
|
+
if not path:find("'", 1, true) then os.execute("chmod 600 '" .. path .. "'") end
|
|
5415
|
+
end or nil,
|
|
5416
|
+
})
|
|
5417
|
+
end
|
|
5418
|
+
|
|
3912
5419
|
-- =============================================================================
|
|
3913
5420
|
-- Heartbeat
|
|
3914
5421
|
-- =============================================================================
|
|
@@ -3955,7 +5462,21 @@ local function main_loop()
|
|
|
3955
5462
|
if now - last_heartbeat >= HEARTBEAT_INTERVAL then
|
|
3956
5463
|
last_heartbeat = now
|
|
3957
5464
|
write_heartbeat()
|
|
3958
|
-
|
|
5465
|
+
publish_status(now, true)
|
|
5466
|
+
else
|
|
5467
|
+
publish_status(now, false)
|
|
5468
|
+
end
|
|
5469
|
+
publish_history(now)
|
|
5470
|
+
save_history(now, false)
|
|
5471
|
+
publish_log()
|
|
5472
|
+
check_health(now)
|
|
5473
|
+
web_watch.check(now)
|
|
5474
|
+
check_control(now)
|
|
5475
|
+
check_source_changes(now)
|
|
5476
|
+
publish_installed_fx(now)
|
|
5477
|
+
publish_used_fx(now)
|
|
5478
|
+
if snaps_lib_ok then snaps_lib.poll(now) end
|
|
5479
|
+
if ext.prefs then ext.prefs.poll() end
|
|
3959
5480
|
|
|
3960
5481
|
reaper.defer(main_loop)
|
|
3961
5482
|
end
|
|
@@ -3964,9 +5485,42 @@ end
|
|
|
3964
5485
|
-- Startup
|
|
3965
5486
|
-- =============================================================================
|
|
3966
5487
|
|
|
3967
|
-
reaper.
|
|
3968
|
-
|
|
3969
|
-
|
|
5488
|
+
-- Find the REAPER web interface port (reaper.ini: csurf_N=HTTP <flags> <port> ...)
|
|
5489
|
+
-- The page pings ExtState ui_seen on every poll; a recent ping means it's already open
|
|
5490
|
+
local function ui_recently_seen()
|
|
5491
|
+
local seen = tonumber(reaper.GetExtState(STATUS_SECTION, "ui_seen"))
|
|
5492
|
+
return seen ~= nil and math.abs(os.time() * 1000 - seen) < 6000
|
|
5493
|
+
end
|
|
5494
|
+
|
|
5495
|
+
-- Open the status page as a small chromeless app window (Chromium --app), else the default browser
|
|
5496
|
+
local function open_status_window(url)
|
|
5497
|
+
local os_name = reaper.GetOS()
|
|
5498
|
+
local cmd
|
|
5499
|
+
if os_name:match("^OSX") or os_name:match("^macOS") then
|
|
5500
|
+
for _, app in ipairs({ "Google Chrome", "Microsoft Edge", "Brave Browser", "Chromium" }) do
|
|
5501
|
+
local bin = "/Applications/" .. app .. ".app/Contents/MacOS/" .. app
|
|
5502
|
+
if file_exists(bin) then
|
|
5503
|
+
cmd = shell_quote(bin) .. " --app=" .. shell_quote(url) .. " --window-size=980,720 >/dev/null 2>&1 &"
|
|
5504
|
+
break
|
|
5505
|
+
end
|
|
5506
|
+
end
|
|
5507
|
+
cmd = cmd or ("open " .. shell_quote(url))
|
|
5508
|
+
elseif os_name:match("^Win") then
|
|
5509
|
+
cmd = 'start "" msedge --app="' .. url .. '" --window-size=980,720'
|
|
5510
|
+
else
|
|
5511
|
+
local q = shell_quote(url)
|
|
5512
|
+
cmd = "(google-chrome --app=" .. q .. " || chromium --app=" .. q .. " || xdg-open " .. q .. ") >/dev/null 2>&1 &"
|
|
5513
|
+
end
|
|
5514
|
+
os.execute(cmd)
|
|
5515
|
+
end
|
|
5516
|
+
|
|
5517
|
+
local web_port = find_web_port()
|
|
5518
|
+
if not web_port then
|
|
5519
|
+
reaper.ShowConsoleMsg("MCP Bridge: running. For the status window, quit REAPER and run `reaper-mcp setup` "
|
|
5520
|
+
.. "(or enable Preferences > Control/OSC/Web > Web browser interface).\n")
|
|
5521
|
+
elseif open_on_start() and not ui_recently_seen() then
|
|
5522
|
+
open_status_window("http://localhost:" .. web_port .. "/mcp_bridge.html?app=window")
|
|
5523
|
+
end
|
|
3970
5524
|
|
|
3971
5525
|
-- Write initial heartbeat
|
|
3972
5526
|
write_heartbeat()
|
|
@@ -3975,7 +5529,16 @@ write_heartbeat()
|
|
|
3975
5529
|
log_event("bridge_start", { reaperVersion = reaper.GetAppVersion(), bridgeDir = bridge_dir })
|
|
3976
5530
|
|
|
3977
5531
|
-- Register shutdown handler
|
|
3978
|
-
|
|
5532
|
+
-- Renders a previous run left behind
|
|
5533
|
+
if ext.render.ok then pcall(ext.render.lib.sweep, reaper) end
|
|
5534
|
+
|
|
5535
|
+
reaper.atexit(function()
|
|
5536
|
+
if ext.render.ok then pcall(ext.render.lib.purge) end
|
|
5537
|
+
log_event("bridge_stop", {})
|
|
5538
|
+
save_history(reaper.time_precise(), true)
|
|
5539
|
+
reaper.DeleteExtState(STATUS_SECTION, "status", false) -- UI flips to offline at once
|
|
5540
|
+
for _, key in ipairs({ "history", "history_long", "log" }) do reaper.DeleteExtState(STATUS_SECTION, key, false) end
|
|
5541
|
+
end)
|
|
3979
5542
|
|
|
3980
5543
|
-- Start the loop
|
|
3981
5544
|
main_loop()
|