@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.
Files changed (116) hide show
  1. package/README.md +19 -570
  2. package/bridge-ui/mcp_bridge.html +129 -0
  3. package/bridge-ui/mcp_bridge.webmanifest +16 -0
  4. package/bridge-ui/mcp_bridge_icon_192.png +0 -0
  5. package/bridge-ui/mcp_bridge_icon_512.png +0 -0
  6. package/bridge-ui/mcp_bridge_sw.js +35 -0
  7. package/claude/agents/plugin-researcher.md +188 -0
  8. package/claude/knowledge/reference/aesthetics-scoring.md +48 -0
  9. package/{knowledge → claude/knowledge}/reference/common-mistakes.md +1 -1
  10. package/claude/knowledge/reference/lorekit-setup.md +36 -0
  11. package/claude/knowledge/reference/memory-protocol.md +377 -0
  12. package/{knowledge → claude/knowledge}/reference/metering.md +5 -5
  13. package/claude/knowledge/reference/perceived-loudness.md +182 -0
  14. package/{knowledge → claude/knowledge}/workflows/editing.md +4 -0
  15. package/{knowledge → claude/knowledge}/workflows/gain-staging.md +21 -23
  16. package/{knowledge → claude/knowledge}/workflows/learn-plugin.md +15 -48
  17. package/{knowledge → claude/knowledge}/workflows/master-bus.md +13 -1
  18. package/claude/knowledge/workflows/section-levels.md +138 -0
  19. package/{knowledge → claude/knowledge}/workflows/session-prep.md +4 -0
  20. package/claude/knowledge/workflows/transient-lighting.md +52 -0
  21. package/claude/skills/critique/SKILL.md +131 -0
  22. package/claude/skills/editing/SKILL.md +281 -0
  23. package/claude/skills/learn-plugin/SKILL.md +210 -0
  24. package/claude/skills/mastering/SKILL.md +151 -0
  25. package/claude/skills/mix-memory/SKILL.md +140 -0
  26. package/claude/skills/mixer/SKILL.md +253 -0
  27. package/claude/skills/new-extension/SKILL.md +324 -0
  28. package/main.js +7843 -1940
  29. package/package.json +17 -15
  30. package/reaper/CLAUDE.md +22 -4
  31. package/reaper/mcp_actions_lib.lua +375 -0
  32. package/reaper/mcp_analyzer.jsfx +27 -12
  33. package/reaper/mcp_arrange_lib.lua +949 -0
  34. package/reaper/mcp_bridge.lua +2321 -758
  35. package/reaper/mcp_edit_views_lib.lua +1378 -0
  36. package/reaper/mcp_envelopes_lib.lua +748 -0
  37. package/reaper/mcp_extensions_lib.lua +55 -0
  38. package/reaper/mcp_fxstate_lib.lua +144 -0
  39. package/reaper/mcp_gain_trace.jsfx +126 -0
  40. package/reaper/mcp_items_lib.lua +877 -0
  41. package/reaper/mcp_lufs_meter.jsfx +16 -13
  42. package/reaper/mcp_midi_lib.lua +555 -0
  43. package/reaper/mcp_mixing_lib.lua +1021 -0
  44. package/reaper/mcp_plan_lib.lua +801 -0
  45. package/reaper/mcp_prefs_lib.lua +127 -0
  46. package/reaper/mcp_project_lib.lua +1088 -0
  47. package/reaper/mcp_recording_lib.lua +595 -0
  48. package/reaper/mcp_render_lib.lua +492 -0
  49. package/reaper/mcp_snapshot_lib.lua +547 -120
  50. package/reaper/mcp_snapshot_next.lua +10 -4
  51. package/reaper/mcp_snapshot_prev.lua +11 -4
  52. package/reaper/mcp_snapshot_quick_save.lua +7 -32
  53. package/reaper/mcp_snapshots_ui_lib.lua +258 -0
  54. package/reaper/mcp_tracks_lib.lua +409 -0
  55. package/sidecar/analysis.py +661 -0
  56. package/sidecar/chords.py +96 -0
  57. package/sidecar/mixcheck.py +295 -0
  58. package/sidecar/requirements-chords.txt +6 -0
  59. package/sidecar/requirements-separation.txt +3 -0
  60. package/sidecar/requirements.txt +17 -0
  61. package/sidecar/separation.py +142 -0
  62. package/sidecar/server.py +622 -0
  63. package/sidecar/tests/test_analysis.py +249 -0
  64. package/sidecar/tests/test_chords.py +117 -0
  65. package/sidecar/tests/test_mixcheck.py +163 -0
  66. package/sidecar/tests/test_separation.py +73 -0
  67. package/sidecar/tests/test_server.py +464 -0
  68. package/sidecar/tests/test_verify.py +243 -0
  69. package/sidecar/tests/test_voice_render.py +268 -0
  70. package/sidecar/tests/test_voice_sketch.py +140 -0
  71. package/sidecar/verify.py +224 -0
  72. package/sidecar/voice_render.py +295 -0
  73. package/sidecar/voice_sketch.py +280 -0
  74. package/claude-agents/critique.md +0 -147
  75. package/claude-agents/editor.md +0 -184
  76. package/claude-agents/levels.md +0 -107
  77. package/claude-agents/mastering.md +0 -129
  78. package/claude-agents/mixer.md +0 -248
  79. package/claude-agents/preflight.md +0 -149
  80. package/claude-agents/producer.md +0 -159
  81. package/claude-agents/setup.md +0 -150
  82. package/claude-agents/stems.md +0 -135
  83. package/claude-rules/architecture.md +0 -39
  84. package/claude-rules/development.md +0 -54
  85. package/claude-rules/lua-bridge.md +0 -50
  86. package/claude-rules/testing.md +0 -42
  87. package/claude-skills/learn-plugin.md +0 -123
  88. package/knowledge/CLAUDE.md +0 -71
  89. package/knowledge/plugins/_template.md +0 -82
  90. package/knowledge/plugins/fabfilter/pro-c-2.md +0 -117
  91. package/knowledge/plugins/fabfilter/pro-l-2.md +0 -95
  92. package/knowledge/plugins/fabfilter/pro-q-3.md +0 -112
  93. package/knowledge/plugins/neural-dsp/helix-native.md +0 -104
  94. package/knowledge/plugins/stock-reaper/js-1175-compressor.md +0 -94
  95. package/knowledge/plugins/stock-reaper/rea-comp.md +0 -100
  96. package/knowledge/plugins/stock-reaper/rea-delay.md +0 -95
  97. package/knowledge/plugins/stock-reaper/rea-eq.md +0 -103
  98. package/knowledge/plugins/stock-reaper/rea-gate.md +0 -99
  99. package/knowledge/plugins/stock-reaper/rea-limit.md +0 -75
  100. package/knowledge/plugins/stock-reaper/rea-verb.md +0 -76
  101. package/knowledge/reference/perceived-loudness.md +0 -122
  102. package/reaper/mcp_snapshot_manager.lua +0 -1001
  103. /package/{knowledge → claude/knowledge}/genres/_template.md +0 -0
  104. /package/{knowledge → claude/knowledge}/genres/electronic.md +0 -0
  105. /package/{knowledge → claude/knowledge}/genres/hip-hop.md +0 -0
  106. /package/{knowledge → claude/knowledge}/genres/metal.md +0 -0
  107. /package/{knowledge → claude/knowledge}/genres/orchestral.md +0 -0
  108. /package/{knowledge → claude/knowledge}/genres/pop.md +0 -0
  109. /package/{knowledge → claude/knowledge}/genres/rock.md +0 -0
  110. /package/{knowledge → claude/knowledge}/reference/compression.md +0 -0
  111. /package/{knowledge → claude/knowledge}/reference/frequencies.md +0 -0
  112. /package/{knowledge → claude/knowledge}/workflows/delivery.md +0 -0
  113. /package/{knowledge → claude/knowledge}/workflows/drum-bus.md +0 -0
  114. /package/{knowledge → claude/knowledge}/workflows/low-end.md +0 -0
  115. /package/{knowledge → claude/knowledge}/workflows/stem-prep.md +0 -0
  116. /package/{knowledge → claude/knowledge}/workflows/vocal-chain.md +0 -0
@@ -0,0 +1,801 @@
1
+ -- =============================================================================
2
+ -- MCP Plan Library — executes a typed list of ops as one all-or-nothing edit
3
+ -- =============================================================================
4
+ -- Loaded via dofile() by mcp_bridge.lua (handler: run_plan). Every function
5
+ -- takes the ReaScript API as `api` instead of reading the `reaper` global, so
6
+ -- the whole executor runs outside REAPER against a fake API in unit tests.
7
+ --
8
+ -- A plan runs in two passes:
9
+ -- 1. validate — resolve every target and check every op; no writes. Any
10
+ -- conflict rejects the whole plan (dryRun stops here and reports).
11
+ -- 2. apply — all ops inside one undo block. If an op fails after writing,
12
+ -- the block is closed and undone, so no partial edit is left behind.
13
+ --
14
+ -- Target references:
15
+ -- track: 3 (0-based index) | "Kick" (exact name, then case-insensitive)
16
+ -- | "{GUID}" | "$ref" (bound by an earlier add_track `as`)
17
+ -- | "3" (digit string: name first, index fallback)
18
+ -- fx: 0 (index on the op's track) | "ReaComp" (unique substring match)
19
+ -- | "$ref" (bound by an earlier add_fx `as`; carries its track)
20
+ -- =============================================================================
21
+
22
+ local M = {}
23
+
24
+ local MAX_REMEMBERED_KEYS = 100
25
+ local REVERT_LABEL = "MCP: run_plan (failed — reverting)"
26
+
27
+ -- -----------------------------------------------------------------------------
28
+ -- Pure helpers (no API access)
29
+ -- -----------------------------------------------------------------------------
30
+
31
+ local function from_db(db)
32
+ return 10 ^ (db / 20)
33
+ end
34
+
35
+ local B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
36
+
37
+ function M.base64_decode(data)
38
+ data = data:gsub("[^%w%+/=]", "")
39
+ return (data:gsub(".", function(ch)
40
+ if ch == "=" then return "" end
41
+ local n = B64:find(ch, 1, true) - 1
42
+ local bits = ""
43
+ for i = 6, 1, -1 do
44
+ bits = bits .. (((n % 2 ^ i) - (n % 2 ^ (i - 1)) > 0) and "1" or "0")
45
+ end
46
+ return bits
47
+ end):gsub("%d%d%d?%d?%d?%d?%d?%d?", function(byte)
48
+ if #byte ~= 8 then return "" end
49
+ local c = 0
50
+ for i = 1, 8 do
51
+ if byte:sub(i, i) == "1" then c = c + 2 ^ (8 - i) end
52
+ end
53
+ return string.char(math.floor(c))
54
+ end))
55
+ end
56
+
57
+ --- Minimal JSON decoder. REAPER and SWS ship no JSON parser (there is no
58
+ --- CF_Json_Parse in SWS 2.14), so the plan's nested ops are decoded here.
59
+ --- Returns value, or nil + error. JSON null decodes to nil.
60
+ function M.json_decode(str)
61
+ local pos = 1
62
+ local decode_value
63
+
64
+ local function fail(msg) error({ json = msg .. " at position " .. pos }, 0) end
65
+ local function skip_ws() pos = str:find("[^ \t\r\n]", pos) or (#str + 1) end
66
+
67
+ local function utf8_char(cp)
68
+ if cp < 0x80 then return string.char(cp) end
69
+ if cp < 0x800 then return string.char(0xC0 | (cp >> 6), 0x80 | (cp & 0x3F)) end
70
+ return string.char(0xE0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3F), 0x80 | (cp & 0x3F))
71
+ end
72
+
73
+ local ESC = { ['"'] = '"', ["\\"] = "\\", ["/"] = "/", b = "\b", f = "\f", n = "\n", r = "\r", t = "\t" }
74
+
75
+ local function decode_string()
76
+ local out = {}
77
+ pos = pos + 1 -- opening quote
78
+ while true do
79
+ local c = str:sub(pos, pos)
80
+ if c == "" then fail("unterminated string") end
81
+ if c == '"' then pos = pos + 1; return table.concat(out) end
82
+ if c == "\\" then
83
+ local e = str:sub(pos + 1, pos + 1)
84
+ if e == "u" then
85
+ local hex = str:sub(pos + 2, pos + 5)
86
+ if not hex:match("^%x%x%x%x$") then fail("bad \\u escape") end
87
+ out[#out + 1] = utf8_char(tonumber(hex, 16))
88
+ pos = pos + 6
89
+ elseif ESC[e] then
90
+ out[#out + 1] = ESC[e]
91
+ pos = pos + 2
92
+ else
93
+ fail("bad escape")
94
+ end
95
+ else
96
+ local s, e = str:find('^[^"\\]+', pos)
97
+ out[#out + 1] = str:sub(s, e)
98
+ pos = e + 1
99
+ end
100
+ end
101
+ end
102
+
103
+ function decode_value()
104
+ skip_ws()
105
+ local c = str:sub(pos, pos)
106
+ if c == "{" then
107
+ local obj = {}
108
+ pos = pos + 1
109
+ skip_ws()
110
+ if str:sub(pos, pos) == "}" then pos = pos + 1; return obj end
111
+ while true do
112
+ skip_ws()
113
+ if str:sub(pos, pos) ~= '"' then fail("expected object key") end
114
+ local k = decode_string()
115
+ skip_ws()
116
+ if str:sub(pos, pos) ~= ":" then fail("expected ':'") end
117
+ pos = pos + 1
118
+ obj[k] = decode_value()
119
+ skip_ws()
120
+ local d = str:sub(pos, pos)
121
+ pos = pos + 1
122
+ if d == "}" then return obj end
123
+ if d ~= "," then fail("expected ',' or '}'") end
124
+ end
125
+ elseif c == "[" then
126
+ local arr, n = {}, 0
127
+ pos = pos + 1
128
+ skip_ws()
129
+ if str:sub(pos, pos) == "]" then pos = pos + 1; return arr end
130
+ while true do
131
+ n = n + 1
132
+ arr[n] = decode_value()
133
+ skip_ws()
134
+ local d = str:sub(pos, pos)
135
+ pos = pos + 1
136
+ if d == "]" then return arr end
137
+ if d ~= "," then fail("expected ',' or ']'") end
138
+ end
139
+ elseif c == '"' then
140
+ return decode_string()
141
+ elseif str:sub(pos, pos + 3) == "true" then
142
+ pos = pos + 4; return true
143
+ elseif str:sub(pos, pos + 4) == "false" then
144
+ pos = pos + 5; return false
145
+ elseif str:sub(pos, pos + 3) == "null" then
146
+ pos = pos + 4; return nil
147
+ else
148
+ local num = str:match("^-?%d+%.?%d*[eE]?[-+]?%d*", pos)
149
+ if not num or num == "" or num == "-" then fail("unexpected character '" .. c .. "'") end
150
+ pos = pos + #num
151
+ return tonumber(num) or fail("bad number")
152
+ end
153
+ end
154
+
155
+ local ok, res = pcall(function()
156
+ local v = decode_value()
157
+ skip_ws()
158
+ if pos <= #str then fail("trailing characters") end
159
+ return v
160
+ end)
161
+ if ok then return res end
162
+ if type(res) == "table" and res.json then return nil, res.json end
163
+ return nil, tostring(res)
164
+ end
165
+
166
+ local function split_lines(chunk)
167
+ local lines = {}
168
+ for line in (chunk .. "\n"):gmatch("(.-)\r?\n") do
169
+ lines[#lines + 1] = line
170
+ end
171
+ -- A chunk ending in "\n" yields one trailing empty line; drop it.
172
+ if lines[#lines] == "" then lines[#lines] = nil end
173
+ return lines
174
+ end
175
+
176
+ local function is_block_open(line, tag)
177
+ local trimmed = line:match("^%s*(.-)%s*$")
178
+ if tag then
179
+ -- "<FXCHAIN" must not also match "<FXCHAIN_REC" (input FX).
180
+ return trimmed == "<" .. tag or trimmed:sub(1, #tag + 2) == "<" .. tag .. " "
181
+ end
182
+ return trimmed:sub(1, 1) == "<"
183
+ end
184
+
185
+ local function is_block_close(line)
186
+ return line:match("^%s*>%s*$") ~= nil
187
+ end
188
+
189
+ --- Find a block that is a DIRECT child of the chunk's outer block.
190
+ --- Returns first_line, last_line (1-based, inclusive) or nil.
191
+ function M.find_block(lines, tag)
192
+ local depth = 0
193
+ local start = nil
194
+ for i, line in ipairs(lines) do
195
+ if is_block_open(line) then
196
+ depth = depth + 1
197
+ if depth == 2 and is_block_open(line, tag) then start = i end
198
+ elseif is_block_close(line) then
199
+ if depth == 2 and start then return start, i end
200
+ depth = depth - 1
201
+ end
202
+ end
203
+ return nil
204
+ end
205
+
206
+ --- The FX names in an FXCHAIN block, lowercased: the first quoted field of
207
+ --- each FX header (`<VST "VST: ReaComp (Cockos)" ...`), or its first word
208
+ --- (`<JS utility/volume ""`).
209
+ function M.fxchain_names(fxchain)
210
+ local names = {}
211
+ for _, line in ipairs(split_lines(fxchain or "")) do
212
+ local tag, rest = line:match("^%s*<(%u[%u_%d]*)%s+(.*)$")
213
+ if tag and tag ~= "FXCHAIN" and tag ~= "PROGRAMENV" and tag ~= "PARMENV" and tag ~= "JS_SER" then
214
+ local name = rest:match('^"([^"]*)"') or rest:match("^(%S+)")
215
+ names[#names + 1] = (name or ""):lower()
216
+ end
217
+ end
218
+ return names
219
+ end
220
+
221
+ --- Extract the FXCHAIN block text from a track state chunk, or nil.
222
+ function M.extract_fxchain(chunk)
223
+ local lines = split_lines(chunk)
224
+ local s, e = M.find_block(lines, "FXCHAIN")
225
+ if not s then return nil end
226
+ return table.concat(lines, "\n", s, e)
227
+ end
228
+
229
+ --- Replace every "FXID {...}" line with a fresh GUID from gen_guid().
230
+ function M.regenerate_fxids(block, gen_guid)
231
+ local lines = split_lines(block)
232
+ for i, line in ipairs(lines) do
233
+ local indent = line:match("^(%s*)FXID%s+%b{}%s*$")
234
+ if indent then lines[i] = indent .. "FXID " .. gen_guid() end
235
+ end
236
+ return table.concat(lines, "\n")
237
+ end
238
+
239
+ --- Put `fxchain` into a track chunk: replace its FXCHAIN if it has one,
240
+ --- otherwise insert it before the first ITEM block (or before the track's
241
+ --- closing ">"). Everything else in the target chunk is kept as is.
242
+ function M.splice_fxchain(target_chunk, fxchain)
243
+ local lines = split_lines(target_chunk)
244
+ local new_lines = split_lines(fxchain)
245
+ local s, e = M.find_block(lines, "FXCHAIN")
246
+ local out = {}
247
+ if s then
248
+ for i = 1, s - 1 do out[#out + 1] = lines[i] end
249
+ for _, l in ipairs(new_lines) do out[#out + 1] = l end
250
+ for i = e + 1, #lines do out[#out + 1] = lines[i] end
251
+ else
252
+ local insert_at = M.find_block(lines, "ITEM") or #lines
253
+ for i = 1, insert_at - 1 do out[#out + 1] = lines[i] end
254
+ for _, l in ipairs(new_lines) do out[#out + 1] = l end
255
+ for i = insert_at, #lines do out[#out + 1] = lines[i] end
256
+ end
257
+ return table.concat(out, "\n") .. "\n"
258
+ end
259
+
260
+ -- -----------------------------------------------------------------------------
261
+ -- Target resolution (reads only)
262
+ -- -----------------------------------------------------------------------------
263
+
264
+ local function track_label(ref)
265
+ return type(ref) == "number" and ("#" .. ref) or ("'" .. tostring(ref) .. "'")
266
+ end
267
+
268
+ local function find_tracks_by_name(api, name, insensitive)
269
+ local hits = {}
270
+ local want = insensitive and name:lower() or name
271
+ for i = 0, api.CountTracks(0) - 1 do
272
+ local tr = api.GetTrack(0, i)
273
+ local _, tname = api.GetTrackName(tr)
274
+ tname = tname or ""
275
+ if (insensitive and tname:lower() or tname) == want then hits[#hits + 1] = tr end
276
+ end
277
+ return hits
278
+ end
279
+
280
+ --- Resolve a track reference to { track = MediaTrack } or { ref = name }
281
+ --- (a track the plan creates). Returns nil, err when unresolvable/ambiguous.
282
+ local function resolve_track(api, ref, refs)
283
+ if ref == -1 or ref == "-1" or (type(ref) == "string" and ref:lower() == "master") then
284
+ return { track = api.GetMasterTrack(0) }
285
+ end
286
+ if type(ref) == "number" then
287
+ local tr = api.GetTrack(0, math.floor(ref))
288
+ if not tr then return nil, "track " .. track_label(ref) .. " not found" end
289
+ return { track = tr }
290
+ end
291
+ if type(ref) ~= "string" or ref == "" then
292
+ return nil, "track reference missing"
293
+ end
294
+ if ref:sub(1, 1) == "$" then
295
+ local bound = refs[ref]
296
+ if not bound then return nil, "reference " .. ref .. " is not defined by an earlier op" end
297
+ if bound.kind ~= "track" then return nil, "reference " .. ref .. " is an FX, not a track" end
298
+ return { ref = ref }
299
+ end
300
+ if ref:sub(1, 1) == "{" then
301
+ for i = 0, api.CountTracks(0) - 1 do
302
+ local tr = api.GetTrack(0, i)
303
+ if api.GetTrackGUID(tr) == ref then return { track = tr } end
304
+ end
305
+ return nil, "track with GUID " .. ref .. " not found"
306
+ end
307
+ local hits = find_tracks_by_name(api, ref, false)
308
+ if #hits == 0 then hits = find_tracks_by_name(api, ref, true) end
309
+ if #hits == 1 then return { track = hits[1] } end
310
+ if #hits > 1 then
311
+ return nil, "track name '" .. ref .. "' is ambiguous (" .. #hits .. " tracks match)"
312
+ end
313
+ if ref:match("^%d+$") then return resolve_track(api, tonumber(ref), refs) end
314
+ return nil, "track '" .. ref .. "' not found"
315
+ end
316
+
317
+ local function fx_installed(api, name)
318
+ if not api.EnumInstalledFX then return true end
319
+ local want = name:lower()
320
+ local i = 0
321
+ while true do
322
+ local ok, fx_name = api.EnumInstalledFX(i)
323
+ if not ok then return false end
324
+ if fx_name and fx_name:lower():find(want, 1, true) then return true end
325
+ i = i + 1
326
+ end
327
+ end
328
+
329
+ --- Find one FX on a track by unique name substring. A digit-only ref (MCP
330
+ --- clients send numbers as strings) matches an exact name only, then falls back
331
+ --- to the FX index — a substring match would turn "0" into "Ozone 10".
332
+ local function find_fx(api, track, ref)
333
+ local want = ref:lower()
334
+ local digits = ref:match("^%d+$") ~= nil
335
+ local found
336
+ for j = 0, api.TrackFX_GetCount(track) - 1 do
337
+ local _, name = api.TrackFX_GetFXName(track, j)
338
+ name = (name or ""):lower()
339
+ if (digits and name == want) or (not digits and name:find(want, 1, true)) then
340
+ if found then return nil, "fx name '" .. ref .. "' matches more than one FX" end
341
+ found = j
342
+ end
343
+ end
344
+ if found then return found end
345
+ local n = digits and tonumber(ref)
346
+ if n and n < api.TrackFX_GetCount(track) then return n end
347
+ return nil, "fx '" .. ref .. "' not found"
348
+ end
349
+
350
+ --- Validate-time version of find_fx, against `chain`: the lowercased FX
351
+ --- names the track will hold when this op runs (its own FX, plus what earlier
352
+ --- ops add or graft). Returns an error string or nil.
353
+ local function check_fx_target(chain, ref)
354
+ if type(ref) == "number" then
355
+ if ref >= #chain then return "fx index " .. ref .. " out of range" end
356
+ return nil
357
+ end
358
+ local want = ref:lower()
359
+ local digits = ref:match("^%d+$") ~= nil
360
+ local hits = 0
361
+ -- A requested fxName ("ReaComp") is usually shorter than the name REAPER
362
+ -- gives the instance ("VST: ReaComp (Cockos)"), so match either way round.
363
+ for _, name in ipairs(chain) do
364
+ if (digits and name == want)
365
+ or (not digits and (name:find(want, 1, true) or want:find(name, 1, true))) then
366
+ hits = hits + 1
367
+ end
368
+ end
369
+ if hits > 1 then return "fx name '" .. ref .. "' matches more than one FX" end
370
+ if hits == 1 then return nil end
371
+ if digits and tonumber(ref) < #chain then return nil end
372
+ return "fx '" .. ref .. "' not found"
373
+ end
374
+
375
+ -- -----------------------------------------------------------------------------
376
+ -- Validation pass
377
+ -- -----------------------------------------------------------------------------
378
+
379
+ local KNOWN_OPS = {
380
+ add_track = true, set_track = true, add_fx = true,
381
+ set_fx_params = true, add_send = true, graft_fxchain = true,
382
+ run_action = true, select_tracks = true,
383
+ copy_items = true, insert_time = true, remove_time = true,
384
+ }
385
+ -- Ops that add or move items, or add tracks: item and track indexes after them differ from
386
+ -- the project the plan was checked against
387
+ local SHIFTS_ITEMS = { add_track = true, copy_items = true, insert_time = true, remove_time = true }
388
+
389
+ --- Validate every op without writing. Returns resolved ops and conflicts.
390
+ --- ctx.actions (mcp_actions_lib.lua) is required only for run_action and select_tracks ops;
391
+ --- ctx.kb_text (reaper-kb.ini) lets it check the steps of a Custom: macro.
392
+ --- ctx.arrange (mcp_arrange_lib.lua) is required only for copy_items, insert_time and remove_time.
393
+ function M.validate(api, ops, ctx)
394
+ ctx = ctx or {}
395
+ local refs = {} -- "$name" -> { kind = "track" | "fx", op = i }
396
+ local resolved = {}
397
+ local conflicts = {}
398
+ local warnings = {}
399
+ local macros -- Custom: macros from ctx.kb_text, parsed on first run_action
400
+ local action_at -- index of the first run_action: later ops can't trust resolved targets
401
+ local shifted_at -- index of the first op that adds tracks or adds/moves items
402
+ -- Track (or "$ref") -> the lowercased FX names it will hold at this point
403
+ -- of the plan: its own chain, then whatever earlier add_fx / graft ops change.
404
+ local chains = {}
405
+ local function chain_of(res)
406
+ local key = res and (res.track or res.ref)
407
+ if not key then return nil end
408
+ if not chains[key] then
409
+ local names = {}
410
+ if res.track then
411
+ for j = 0, api.TrackFX_GetCount(res.track) - 1 do
412
+ local _, name = api.TrackFX_GetFXName(res.track, j)
413
+ names[#names + 1] = (name or ""):lower()
414
+ end
415
+ end
416
+ chains[key] = names
417
+ end
418
+ return chains[key]
419
+ end
420
+
421
+ local function conflict(i, op, msg)
422
+ conflicts[#conflicts + 1] = { op = i - 1, type = op.op, error = msg }
423
+ end
424
+
425
+ for i, op in ipairs(ops) do
426
+ local r = { op = op, index = i - 1 }
427
+ local t = op.op
428
+ if not KNOWN_OPS[t] then
429
+ conflict(i, op, "unknown op type '" .. tostring(t) .. "'")
430
+ elseif action_at and t ~= "run_action" then
431
+ -- Targets resolve before anything runs; an action may add, delete or
432
+ -- reorder tracks and FX, so a later target could point at the wrong one.
433
+ conflict(i, op, "only run_action may follow a run_action (op " .. (action_at - 1)
434
+ .. "): an action can add, delete or reorder tracks; send the rest as a new plan")
435
+ else
436
+ local function need_track(field)
437
+ local res, err = resolve_track(api, op[field], refs)
438
+ if not res then conflict(i, op, field .. ": " .. err) end
439
+ return res
440
+ end
441
+
442
+ local fx_is_ref = type(op.fx) == "string" and op.fx:sub(1, 1) == "$"
443
+ if t == "set_track" or t == "add_fx" or t == "graft_fxchain"
444
+ or (t == "set_fx_params" and not fx_is_ref) then
445
+ r.track = need_track("track")
446
+ end
447
+
448
+ if t == "add_fx" then
449
+ local chain = chain_of(r.track)
450
+ if chain and type(op.fxName) == "string" then chain[#chain + 1] = op.fxName:lower() end
451
+ if type(op.fxName) ~= "string" or op.fxName == "" then
452
+ conflict(i, op, "fxName required")
453
+ elseif not fx_installed(api, op.fxName) then
454
+ -- A warning, not a conflict: AddByName accepts names (JS paths,
455
+ -- prefixes) that the installed-FX list spells differently.
456
+ warnings[#warnings + 1] = { op = i - 1, type = t, warning = "FX '" .. op.fxName .. "' not found in the installed FX list" }
457
+ end
458
+ elseif t == "set_fx_params" then
459
+ if type(op.params) ~= "table" or #op.params == 0 then
460
+ conflict(i, op, "params array required")
461
+ end
462
+ if fx_is_ref then
463
+ local bound = refs[op.fx]
464
+ if not bound then
465
+ conflict(i, op, "reference " .. op.fx .. " is not defined by an earlier op")
466
+ elseif bound.kind ~= "fx" then
467
+ conflict(i, op, "reference " .. op.fx .. " is a track, not an FX")
468
+ end
469
+ elseif type(op.fx) ~= "number" and type(op.fx) ~= "string" then
470
+ conflict(i, op, "fx reference missing")
471
+ elseif chain_of(r.track) then
472
+ local err = check_fx_target(chain_of(r.track), op.fx)
473
+ if err then conflict(i, op, err) end
474
+ end
475
+ elseif t == "add_send" then
476
+ r.from = need_track("from")
477
+ r.to = need_track("to")
478
+ -- REAPER creates nothing for a send into or out of the master.
479
+ local master = api.GetMasterTrack(0)
480
+ if (r.from and r.from.track == master) or (r.to and r.to.track == master) then
481
+ conflict(i, op, "a send cannot involve the master track: use set_track_info masterSend")
482
+ end
483
+ elseif t == "graft_fxchain" then
484
+ local grafted
485
+ if op.fromChunk then
486
+ local ok, chunk = pcall(M.base64_decode, op.fromChunk)
487
+ r.fxchain = ok and M.extract_fxchain(chunk) or nil
488
+ if r.fxchain then grafted = M.fxchain_names(r.fxchain) end
489
+ if not r.fxchain then conflict(i, op, "fromChunk holds no FXCHAIN block") end
490
+ elseif op.from ~= nil then
491
+ r.source = need_track("from")
492
+ local key = r.source and (r.source.track or r.source.ref)
493
+ if key and chains[key] then
494
+ -- An earlier op changed (or created) the source: copy what it will hold.
495
+ grafted = { table.unpack(chains[key]) }
496
+ elseif r.source and r.source.track then
497
+ local _, chunk = api.GetTrackStateChunk(r.source.track, "", false)
498
+ local block = M.extract_fxchain(chunk or "")
499
+ if block then grafted = M.fxchain_names(block)
500
+ else conflict(i, op, "source track has no FX chain") end
501
+ end
502
+ else
503
+ conflict(i, op, "graft_fxchain needs `from` or `fromChunk`")
504
+ end
505
+ -- Later ops on the target see the grafted chain, not the old one.
506
+ local key = r.track and (r.track.track or r.track.ref)
507
+ if grafted and key then chains[key] = grafted end
508
+ elseif t == "select_tracks" then
509
+ if not ctx.actions then
510
+ conflict(i, op, "mcp_actions_lib.lua is not loaded")
511
+ elseif type(op.tracks) ~= "table" or #op.tracks == 0 then
512
+ conflict(i, op, "tracks array required")
513
+ else
514
+ r.tracks = {}
515
+ for k, ref in ipairs(op.tracks) do
516
+ local res, err = resolve_track(api, ref, refs)
517
+ if not res then conflict(i, op, "tracks[" .. (k - 1) .. "]: " .. err) end
518
+ r.tracks[k] = res
519
+ end
520
+ end
521
+ elseif ctx.arrange == nil and (t == "copy_items" or t == "insert_time" or t == "remove_time") then
522
+ conflict(i, op, "mcp_arrange_lib.lua is not loaded")
523
+ elseif t == "copy_items" and shifted_at then
524
+ -- Item indexes are read from the project as it is now
525
+ conflict(i, op, "copy_items names items by index, and op " .. (shifted_at - 1) .. " (" .. ops[shifted_at].op
526
+ .. ") changes them first; put copy_items earlier or send it as a new plan")
527
+ elseif t == "copy_items" or t == "insert_time" or t == "remove_time" then
528
+ local err = ctx.arrange.check_op(api, op)
529
+ if err then conflict(i, op, err) end
530
+ elseif t == "run_action" then
531
+ action_at = action_at or i
532
+ local actions = ctx.actions
533
+ if not actions then
534
+ conflict(i, op, "mcp_actions_lib.lua is not loaded")
535
+ else
536
+ local ok, sec = pcall(actions.section_id, op.section)
537
+ if not ok then
538
+ conflict(i, op, sec)
539
+ else
540
+ local id, name = actions.resolve(api, op.action, sec)
541
+ if not id then
542
+ conflict(i, op, name)
543
+ else
544
+ macros = macros or actions.parse_kb(ctx.kb_text)
545
+ local refused = actions.check_runnable(api, id, name, sec, op.allowDialog == true, macros)
546
+ if refused then conflict(i, op, refused) end
547
+ r.action = { id = id, name = name, section = sec }
548
+ end
549
+ end
550
+ end
551
+ end
552
+
553
+ if SHIFTS_ITEMS[t] then shifted_at = shifted_at or i end
554
+
555
+ if op.as ~= nil then
556
+ if type(op.as) ~= "string" or op.as:sub(1, 1) ~= "$" then
557
+ conflict(i, op, "`as` must start with '$'")
558
+ elseif refs[op.as] then
559
+ conflict(i, op, "reference " .. op.as .. " is already defined")
560
+ elseif t == "add_track" then
561
+ refs[op.as] = { kind = "track", op = i }
562
+ elseif t == "add_fx" then
563
+ refs[op.as] = { kind = "fx", op = i }
564
+ else
565
+ conflict(i, op, "`as` is only allowed on add_track and add_fx")
566
+ end
567
+ end
568
+ end
569
+ resolved[i] = r
570
+ end
571
+ return resolved, conflicts, warnings
572
+ end
573
+
574
+ -- -----------------------------------------------------------------------------
575
+ -- Apply pass
576
+ -- -----------------------------------------------------------------------------
577
+
578
+ local function fx_index_by_guid(api, track, guid)
579
+ for j = 0, api.TrackFX_GetCount(track) - 1 do
580
+ if api.TrackFX_GetFXGUID(track, j) == guid then return j end
581
+ end
582
+ return nil
583
+ end
584
+
585
+ local function apply_op(api, r, bound, ctx)
586
+ local op = r.op
587
+ local t = op.op
588
+
589
+ local function track_of(res)
590
+ if res.track then return res.track end
591
+ return bound[res.ref].track
592
+ end
593
+
594
+ if t == "add_track" then
595
+ -- REAPER clamps an index past the end; read the new track back at the same place.
596
+ local idx = math.min(op.index or api.CountTracks(0), api.CountTracks(0))
597
+ api.InsertTrackAtIndex(idx, true)
598
+ local tr = api.GetTrack(0, idx)
599
+ if op.name then api.GetSetMediaTrackInfo_String(tr, "P_NAME", op.name, true) end
600
+ if op.as then bound[op.as] = { track = tr } end
601
+ return { track = idx, guid = api.GetTrackGUID(tr) }
602
+
603
+ elseif t == "set_track" then
604
+ local tr = track_of(r.track)
605
+ if op.volume ~= nil then api.SetMediaTrackInfo_Value(tr, "D_VOL", from_db(op.volume)) end
606
+ if op.pan ~= nil then api.SetMediaTrackInfo_Value(tr, "D_PAN", op.pan) end
607
+ if op.mute ~= nil then api.SetMediaTrackInfo_Value(tr, "B_MUTE", op.mute) end
608
+ if op.solo ~= nil then api.SetMediaTrackInfo_Value(tr, "I_SOLO", op.solo) end
609
+ if op.recordArm ~= nil then api.SetMediaTrackInfo_Value(tr, "I_RECARM", op.recordArm) end
610
+ if op.phase ~= nil then api.SetMediaTrackInfo_Value(tr, "B_PHASE", op.phase) end
611
+ if op.input ~= nil then api.SetMediaTrackInfo_Value(tr, "I_RECINPUT", op.input) end
612
+ if op.name ~= nil then api.GetSetMediaTrackInfo_String(tr, "P_NAME", op.name, true) end
613
+ return {}
614
+
615
+ elseif t == "add_fx" then
616
+ local tr = track_of(r.track)
617
+ -- TrackFX_AddByName: -1 appends; -1000 - n inserts at position n.
618
+ local pos = op.position ~= nil and (-1000 - op.position) or -1
619
+ local fx = api.TrackFX_AddByName(tr, op.fxName, false, pos)
620
+ if fx < 0 then error("FX not found: " .. op.fxName, 0) end
621
+ if op.enabled ~= nil then api.TrackFX_SetEnabled(tr, fx, op.enabled == 1 or op.enabled == true) end
622
+ for _, p in ipairs(op.params or {}) do
623
+ if not api.TrackFX_SetParam(tr, fx, p.index, p.value) then
624
+ error("failed to set param " .. p.index .. " on " .. op.fxName, 0)
625
+ end
626
+ end
627
+ local guid = api.TrackFX_GetFXGUID(tr, fx)
628
+ if op.as then bound[op.as] = { track = tr, fxGuid = guid } end
629
+ local _, name = api.TrackFX_GetFXName(tr, fx)
630
+ return { fxIndex = fx, fxName = name }
631
+
632
+ elseif t == "set_fx_params" then
633
+ local tr, fx
634
+ if type(op.fx) == "string" and op.fx:sub(1, 1) == "$" then
635
+ tr = bound[op.fx].track
636
+ fx = fx_index_by_guid(api, tr, bound[op.fx].fxGuid)
637
+ else
638
+ tr = track_of(r.track)
639
+ if type(op.fx) == "number" then
640
+ fx = op.fx
641
+ else
642
+ local err
643
+ fx, err = find_fx(api, tr, op.fx)
644
+ if not fx then error(err, 0) end
645
+ end
646
+ end
647
+ if not fx or fx >= api.TrackFX_GetCount(tr) then error("fx " .. tostring(op.fx) .. " not found", 0) end
648
+ for _, p in ipairs(op.params) do
649
+ if not api.TrackFX_SetParam(tr, fx, p.index, p.value) then
650
+ error("failed to set param " .. p.index .. " on fx " .. fx, 0)
651
+ end
652
+ end
653
+ return { fxIndex = fx, updated = #op.params }
654
+
655
+ elseif t == "add_send" then
656
+ local src, dst = track_of(r.from), track_of(r.to)
657
+ local s = api.CreateTrackSend(src, dst)
658
+ if s < 0 then error("could not create send", 0) end
659
+ if op.volume ~= nil then api.SetTrackSendInfo_Value(src, 0, s, "D_VOL", from_db(op.volume)) end
660
+ if op.pan ~= nil then api.SetTrackSendInfo_Value(src, 0, s, "D_PAN", op.pan) end
661
+ if op.mute ~= nil then api.SetTrackSendInfo_Value(src, 0, s, "B_MUTE", op.mute) end
662
+ return { sendIndex = s }
663
+
664
+ elseif t == "graft_fxchain" then
665
+ local fxchain = r.fxchain
666
+ if not fxchain then
667
+ local _, src_chunk = api.GetTrackStateChunk(track_of(r.source), "", false)
668
+ fxchain = M.extract_fxchain(src_chunk or "")
669
+ if not fxchain then error("source track has no FX chain", 0) end
670
+ end
671
+ fxchain = M.regenerate_fxids(fxchain, function() return api.genGuid("") end)
672
+ local tr = track_of(r.track)
673
+ local _, target_chunk = api.GetTrackStateChunk(tr, "", false)
674
+ if not api.SetTrackStateChunk(tr, M.splice_fxchain(target_chunk, fxchain), false) then
675
+ error("SetTrackStateChunk failed", 0)
676
+ end
677
+ return { fxCount = api.TrackFX_GetCount(tr) }
678
+
679
+ elseif t == "select_tracks" then
680
+ local tracks = {}
681
+ for k, res in ipairs(r.tracks) do tracks[k] = track_of(res) end
682
+ return { selected = ctx.actions.select_tracks(api, tracks, op.mode) }
683
+
684
+ elseif t == "run_action" then
685
+ local a = r.action
686
+ local toggle = ctx.actions.exec(api, a.id, a.section)
687
+ return { id = a.id, name = a.name, toggle = toggle }
688
+
689
+ elseif ctx.arrange and ctx.arrange.PLAN_OPS[t] then
690
+ return ctx.arrange.apply_op(api, op)
691
+ end
692
+ end
693
+
694
+ local function remember(ctx, key, result)
695
+ ctx.applied = ctx.applied or {}
696
+ ctx.applied_order = ctx.applied_order or {}
697
+ ctx.applied[key] = result
698
+ table.insert(ctx.applied_order, key)
699
+ if #ctx.applied_order > MAX_REMEMBERED_KEYS then
700
+ ctx.applied[table.remove(ctx.applied_order, 1)] = nil
701
+ end
702
+ end
703
+
704
+ local function describe(resolved)
705
+ local out = {}
706
+ for i, r in ipairs(resolved) do
707
+ out[i] = { index = r.index, op = r.op.op }
708
+ if r.action then out[i].action = { id = r.action.id, name = r.action.name } end
709
+ end
710
+ return out
711
+ end
712
+
713
+ --- Run a plan. `ctx` persists across calls (idempotency memory) and supplies
714
+ --- `snapshot_save(name)` for the optional pre-apply snapshot.
715
+ --- Returns data, or nil + error string (the bridge handler contract).
716
+ function M.run_plan(api, params, ctx)
717
+ ctx = ctx or {}
718
+ local ops = params.ops
719
+ if type(ops) == "string" then
720
+ local val, err
721
+ if api.CF_Json_Parse then
722
+ local ok
723
+ ok, val = api.CF_Json_Parse(ops)
724
+ if not ok then val = nil end
725
+ else
726
+ val, err = M.json_decode(ops)
727
+ end
728
+ if type(val) ~= "table" then
729
+ return nil, "ops is not a valid JSON array" .. (err and (" (" .. err .. ")") or "") .. " — nothing was changed"
730
+ end
731
+ ops = val
732
+ end
733
+ if type(ops) ~= "table" or #ops == 0 then return nil, "ops array required" end
734
+
735
+ local key = params.idempotencyKey
736
+ if key and key ~= "" and ctx.applied and ctx.applied[key] then
737
+ return { alreadyApplied = true, idempotencyKey = key, result = ctx.applied[key] }
738
+ end
739
+
740
+ local resolved, conflicts, warnings = M.validate(api, ops, ctx)
741
+
742
+ if params.dryRun then
743
+ return {
744
+ dryRun = true, valid = #conflicts == 0, ops = describe(resolved),
745
+ conflicts = conflicts, warnings = warnings,
746
+ }
747
+ end
748
+ if #conflicts > 0 then
749
+ local msgs = {}
750
+ for _, c in ipairs(conflicts) do
751
+ msgs[#msgs + 1] = "op " .. c.op .. " (" .. tostring(c.type) .. "): " .. c.error
752
+ end
753
+ return nil, "Plan rejected, nothing was changed — " .. table.concat(msgs, "; ")
754
+ end
755
+
756
+ if params.snapshotBefore and params.snapshotBefore ~= "" then
757
+ if not ctx.snapshot_save then return nil, "snapshotBefore is not supported by this bridge" end
758
+ local _, err = ctx.snapshot_save(params.snapshotBefore)
759
+ if err then return nil, "Pre-apply snapshot failed, nothing was changed — " .. err end
760
+ end
761
+
762
+ local label = params.label or ("MCP: run_plan (" .. #ops .. " ops)")
763
+ local bound = {}
764
+ local results = {}
765
+ local failed_at, failure
766
+
767
+ if api.PreventUIRefresh then api.PreventUIRefresh(1) end
768
+ api.Undo_BeginBlock()
769
+ for i, r in ipairs(resolved) do
770
+ local ok, res = pcall(apply_op, api, r, bound, ctx)
771
+ if not ok then
772
+ failed_at, failure = i - 1, tostring(res)
773
+ break
774
+ end
775
+ results[i] = { index = i - 1, op = r.op.op, result = res }
776
+ end
777
+
778
+ if failed_at then
779
+ api.Undo_EndBlock(REVERT_LABEL, -1)
780
+ -- Only undo our own block: if the failing op wrote nothing, no undo
781
+ -- point exists and the top of the stack belongs to the user.
782
+ if api.Undo_CanUndo2(0) == REVERT_LABEL then api.Undo_DoUndo2(0) end
783
+ if api.PreventUIRefresh then api.PreventUIRefresh(-1) end
784
+ return nil, "op " .. failed_at .. " (" .. tostring(ops[failed_at + 1].op) .. ") failed: "
785
+ .. failure .. " — plan reverted, nothing was changed"
786
+ end
787
+
788
+ api.Undo_EndBlock(label, -1)
789
+ if api.PreventUIRefresh then api.PreventUIRefresh(-1) end
790
+ if api.TrackList_AdjustWindows then api.TrackList_AdjustWindows(false) end
791
+
792
+ local data = { success = true, applied = #results, results = results }
793
+ if key and key ~= "" then
794
+ data.idempotencyKey = key
795
+ remember(ctx, key, data)
796
+ end
797
+ return data
798
+ end
799
+
800
+ M.resolve_track = resolve_track
801
+ return M