@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,877 @@
1
+ -- mcp_items_lib.lua — items, takes, take FX, item color/fades, and FX
2
+ -- housekeeping (move/copy/rename FX, .RfxChain files, track templates).
3
+ --
4
+ -- Pure logic: every function takes the ReaScript API as `api` and a ctx:
5
+ -- ctx.plan mcp_plan_lib.lua (chunk helpers shared with graft_fxchain)
6
+ -- ctx.fs { exists(path), read(path), write(path, text), mkdir(dir), list(dir) -> files, dirs }
7
+ -- so it runs under a fake API and an in-memory file system in unit tests.
8
+ -- Loaded by mcp_bridge.lua with dofile.
9
+ --
10
+ -- Every function validates all of its input before the first write, so a
11
+ -- refused request changes nothing. Writes happen inside one undo block.
12
+
13
+ local M = {}
14
+
15
+ M.FADE_SHAPES = 7 -- C_FADEINSHAPE / C_FADEOUTSHAPE take 0..6
16
+ M.FX_CHAIN_EXT = ".RfxChain"
17
+ M.TEMPLATE_EXT = ".RTrackTemplate"
18
+
19
+ -- -----------------------------------------------------------------------------
20
+ -- Lookups (reads only)
21
+ -- -----------------------------------------------------------------------------
22
+
23
+ local function int(v)
24
+ local n = tonumber(v)
25
+ if not n or n ~= math.floor(n) then return nil end
26
+ return n
27
+ end
28
+
29
+ -- allow_master: -1 means the master track (FX tools); items never live there.
30
+ local function get_track(api, idx, field, allow_master)
31
+ field = field or "trackIndex"
32
+ local i = int(idx)
33
+ if not i then return nil, field .. " required" end
34
+ local tr
35
+ if i == -1 and allow_master then tr = api.GetMasterTrack(0)
36
+ elseif i >= 0 then tr = api.GetTrack(0, i) end
37
+ if not tr then return nil, "Track " .. i .. " not found" end
38
+ return tr, i
39
+ end
40
+
41
+ local function get_item(api, p)
42
+ local tr, err = get_track(api, p.trackIndex)
43
+ if not tr then return nil, err end
44
+ local i = int(p.itemIndex)
45
+ if not i then return nil, "itemIndex required" end
46
+ local n = api.CountTrackMediaItems(tr)
47
+ if i < 0 or i >= n then
48
+ return nil, "Item " .. i .. " not found (track has " .. n .. " items)"
49
+ end
50
+ return api.GetTrackMediaItem(tr, i), tr
51
+ end
52
+
53
+ -- Active take when takeIndex is absent.
54
+ local function get_take(api, item, take_index)
55
+ local n = api.CountTakes(item)
56
+ if take_index == nil then
57
+ local take = api.GetActiveTake(item)
58
+ if not take then return nil, "item has no takes" end
59
+ return take
60
+ end
61
+ local i = int(take_index)
62
+ if not i or i < 0 or i >= n then
63
+ return nil, "Take " .. tostring(take_index) .. " not found (item has " .. n .. " takes)"
64
+ end
65
+ local take = api.GetTake(item, i)
66
+ if not take then return nil, "Take " .. i .. " is an empty take slot" end
67
+ return take
68
+ end
69
+
70
+ local function take_index_of(api, item, take)
71
+ for i = 0, api.CountTakes(item) - 1 do
72
+ if api.GetTake(item, i) == take then return i end
73
+ end
74
+ return -1
75
+ end
76
+
77
+ local function source_file(api, take)
78
+ local src = api.GetMediaItemTake_Source(take)
79
+ if not src then return "" end
80
+ -- Documented as returning the name; some builds return (retval, name).
81
+ local a, b = api.GetMediaSourceFileName(src, "")
82
+ if type(b) == "string" then return b end
83
+ return type(a) == "string" and a or ""
84
+ end
85
+
86
+ local function take_fx_count(api, take)
87
+ return api.TakeFX_GetCount and api.TakeFX_GetCount(take) or 0
88
+ end
89
+
90
+ local function check_fx_index(count, idx, what)
91
+ local i = int(idx)
92
+ if not i then return nil, "fxIndex required" end
93
+ if i < 0 or i >= count then
94
+ return nil, "FX " .. i .. " not found (" .. what .. " has " .. count .. " FX)"
95
+ end
96
+ return i
97
+ end
98
+
99
+ local function track_fx_names(api, tr)
100
+ local names = {}
101
+ for i = 0, api.TrackFX_GetCount(tr) - 1 do
102
+ local _, name = api.TrackFX_GetFXName(tr, i, "")
103
+ names[#names + 1] = name or ""
104
+ end
105
+ return names
106
+ end
107
+
108
+ local function take_fx_list(api, take)
109
+ local list = {}
110
+ for i = 0, take_fx_count(api, take) - 1 do
111
+ local _, name = api.TakeFX_GetFXName(take, i, "")
112
+ list[#list + 1] = {
113
+ fxIndex = i,
114
+ name = name or "",
115
+ enabled = api.TakeFX_GetEnabled(take, i),
116
+ paramCount = api.TakeFX_GetNumParams(take, i),
117
+ }
118
+ end
119
+ return list
120
+ end
121
+
122
+ local function with_undo(api, label, fn)
123
+ api.Undo_BeginBlock()
124
+ local ok, res, err = pcall(fn)
125
+ api.Undo_EndBlock(label, -1)
126
+ if api.UpdateArrange then api.UpdateArrange() end
127
+ if not ok then return nil, tostring(res) end
128
+ return res, err
129
+ end
130
+
131
+ -- -----------------------------------------------------------------------------
132
+ -- Insert an audio file as a new item
133
+ -- -----------------------------------------------------------------------------
134
+
135
+ local function basename(path)
136
+ return path:match("([^/\\]+)$") or path
137
+ end
138
+
139
+ local function find_item_index(api, tr, item)
140
+ for i = 0, api.CountTrackMediaItems(tr) - 1 do
141
+ if api.GetTrackMediaItem(tr, i) == item then return i end
142
+ end
143
+ return -1
144
+ end
145
+
146
+ function M.insert_audio_file(api, p, ctx)
147
+ local tr, track_idx = get_track(api, p.trackIndex)
148
+ if not tr then return nil, track_idx end
149
+ local path = p.filePath
150
+ if type(path) ~= "string" or path == "" then return nil, "filePath required" end
151
+ if not ctx.fs.exists(path) then return nil, "file not found: " .. path end
152
+ local position = p.position
153
+ if position == nil then position = api.GetCursorPosition() end
154
+ position = tonumber(position)
155
+ if not position or position < 0 then return nil, "position must be a number >= 0" end
156
+
157
+ local src = api.PCM_Source_CreateFromFile(path)
158
+ local length, in_beats = 0, false
159
+ if src then length, in_beats = api.GetMediaSourceLength(src) end
160
+ -- A MIDI file's length comes back in quarter notes: not audio, and not seconds.
161
+ if not src or not length or length <= 0 or in_beats then
162
+ if src and api.PCM_Source_Destroy then api.PCM_Source_Destroy(src) end
163
+ return nil, "REAPER cannot read '" .. path .. "' as audio"
164
+ end
165
+
166
+ return with_undo(api, "MCP: Insert audio file " .. basename(path), function()
167
+ local item = api.AddMediaItemToTrack(tr)
168
+ local take = api.AddTakeToMediaItem(item)
169
+ api.SetMediaItemTake_Source(take, src)
170
+ api.SetMediaItemInfo_Value(item, "D_POSITION", position)
171
+ api.SetMediaItemInfo_Value(item, "D_LENGTH", length)
172
+ api.GetSetMediaItemTakeInfo_String(take, "P_NAME", basename(path), true)
173
+ if api.UpdateItemInProject then api.UpdateItemInProject(item) end
174
+ return {
175
+ trackIndex = track_idx,
176
+ itemIndex = find_item_index(api, tr, item),
177
+ position = api.GetMediaItemInfo_Value(item, "D_POSITION"),
178
+ length = api.GetMediaItemInfo_Value(item, "D_LENGTH"),
179
+ takeName = basename(path),
180
+ sourceFile = source_file(api, take),
181
+ }
182
+ end)
183
+ end
184
+
185
+ -- Stems from separate_stems: a folder track holding one track per stem, each with its
186
+ -- file at `position`, inserted after `afterTrack` (default: at the end) in one undo point.
187
+ -- Every file is checked before anything is written. The folder starts muted: the stems
188
+ -- sum back to their source, so playing both would double the level.
189
+ function M.insert_stems(api, p, ctx)
190
+ local files = p.files
191
+ if type(files) ~= "table" or #files == 0 then return nil, "files required: a list of {name, path}" end
192
+ local position = tonumber(p.position or 0)
193
+ if not position or position < 0 then return nil, "position must be a number >= 0" end
194
+ local count = api.CountTracks(0)
195
+ local at = count
196
+ if p.afterTrack ~= nil then
197
+ local after = int(p.afterTrack)
198
+ if not after or after < -1 or after >= count then return nil, "afterTrack " .. tostring(p.afterTrack) .. " not found" end
199
+ -- After a folder parent means after its last child, so the stems never land inside that folder
200
+ local last, depth = after, after >= 0 and api.GetMediaTrackInfo_Value(api.GetTrack(0, after), "I_FOLDERDEPTH") or 0
201
+ while depth > 0 and last + 1 < count do
202
+ last = last + 1
203
+ depth = depth + api.GetMediaTrackInfo_Value(api.GetTrack(0, last), "I_FOLDERDEPTH")
204
+ end
205
+ at = last + 1
206
+ end
207
+ local sources = {}
208
+ for i, f in ipairs(files) do
209
+ if type(f) ~= "table" or type(f.path) ~= "string" or f.path == "" then return nil, "files[" .. i .. "] needs a path" end
210
+ if not ctx.fs.exists(f.path) then return nil, "file not found: " .. f.path end
211
+ local src = api.PCM_Source_CreateFromFile(f.path)
212
+ local length, in_beats = 0, false
213
+ if src then length, in_beats = api.GetMediaSourceLength(src) end
214
+ if not src or not length or length <= 0 or in_beats then
215
+ for _, s in ipairs(sources) do if api.PCM_Source_Destroy then api.PCM_Source_Destroy(s.src) end end
216
+ if src and api.PCM_Source_Destroy then api.PCM_Source_Destroy(src) end
217
+ return nil, "REAPER cannot read '" .. f.path .. "' as audio"
218
+ end
219
+ sources[i] = { src = src, length = length, name = f.name or basename(f.path) }
220
+ end
221
+ local folder_name = p.folderName or "Stems"
222
+
223
+ return with_undo(api, "MCP: Insert stems (" .. #sources .. ")", function()
224
+ api.InsertTrackAtIndex(at, true)
225
+ local folder = api.GetTrack(0, at)
226
+ api.GetSetMediaTrackInfo_String(folder, "P_NAME", folder_name, true)
227
+ api.SetMediaTrackInfo_Value(folder, "I_FOLDERDEPTH", 1)
228
+ api.SetMediaTrackInfo_Value(folder, "B_MUTE", 1)
229
+ local tracks = {}
230
+ for i, s in ipairs(sources) do
231
+ local idx = at + i
232
+ api.InsertTrackAtIndex(idx, true)
233
+ local tr = api.GetTrack(0, idx)
234
+ api.GetSetMediaTrackInfo_String(tr, "P_NAME", s.name, true)
235
+ local item = api.AddMediaItemToTrack(tr)
236
+ local take = api.AddTakeToMediaItem(item)
237
+ api.SetMediaItemTake_Source(take, s.src)
238
+ api.SetMediaItemInfo_Value(item, "D_POSITION", position)
239
+ api.SetMediaItemInfo_Value(item, "D_LENGTH", s.length)
240
+ api.GetSetMediaItemTakeInfo_String(take, "P_NAME", s.name, true)
241
+ if api.UpdateItemInProject then api.UpdateItemInProject(item) end
242
+ if i == #sources then api.SetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH", -1) end
243
+ tracks[#tracks + 1] = { trackIndex = idx, name = s.name, position = position, length = s.length }
244
+ end
245
+ return { folderIndex = at, folderName = folder_name, muted = true, tracks = tracks }
246
+ end)
247
+ end
248
+
249
+ -- -----------------------------------------------------------------------------
250
+ -- Takes
251
+ -- -----------------------------------------------------------------------------
252
+
253
+ function M.list_takes(api, p)
254
+ local item, tr = get_item(api, p)
255
+ if not item then return nil, tr end
256
+ local active = api.GetActiveTake(item)
257
+ local takes = {}
258
+ for i = 0, api.CountTakes(item) - 1 do
259
+ local take = api.GetTake(item, i)
260
+ if take then
261
+ takes[#takes + 1] = {
262
+ takeIndex = i,
263
+ name = api.GetTakeName(take) or "",
264
+ active = take == active,
265
+ isMidi = api.TakeIsMIDI(take) == true,
266
+ sourceFile = source_file(api, take),
267
+ fxCount = take_fx_count(api, take),
268
+ }
269
+ else
270
+ takes[#takes + 1] = { takeIndex = i, empty = true }
271
+ end
272
+ end
273
+ return {
274
+ trackIndex = int(p.trackIndex),
275
+ itemIndex = int(p.itemIndex),
276
+ activeTakeIndex = active and take_index_of(api, item, active) or -1,
277
+ takes = takes,
278
+ }
279
+ end
280
+
281
+ function M.set_active_take(api, p)
282
+ local item, tr = get_item(api, p)
283
+ if not item then return nil, tr end
284
+ if p.takeIndex == nil then return nil, "takeIndex required" end
285
+ local take, err = get_take(api, item, p.takeIndex)
286
+ if not take then return nil, err end
287
+ return with_undo(api, "MCP: Set active take", function()
288
+ api.SetActiveTake(take)
289
+ if api.UpdateItemInProject then api.UpdateItemInProject(item) end
290
+ return M.list_takes(api, p)
291
+ end)
292
+ end
293
+
294
+ -- -----------------------------------------------------------------------------
295
+ -- Take FX
296
+ -- -----------------------------------------------------------------------------
297
+
298
+ local function item_take(api, p)
299
+ local item, tr = get_item(api, p)
300
+ if not item then return nil, tr end
301
+ local take, err = get_take(api, item, p.takeIndex)
302
+ if not take then return nil, err end
303
+ return take, item
304
+ end
305
+
306
+ local function take_fx_result(api, p, take, item, extra)
307
+ local res = {
308
+ trackIndex = int(p.trackIndex),
309
+ itemIndex = int(p.itemIndex),
310
+ takeIndex = take_index_of(api, item, take),
311
+ fx = take_fx_list(api, take),
312
+ }
313
+ for k, v in pairs(extra or {}) do res[k] = v end
314
+ return res
315
+ end
316
+
317
+ function M.get_take_fx(api, p)
318
+ local take, item = item_take(api, p)
319
+ if not take then return nil, item end
320
+ if p.fxIndex == nil then return take_fx_result(api, p, take, item) end
321
+ local fx, err = check_fx_index(take_fx_count(api, take), p.fxIndex, "take")
322
+ if not fx then return nil, err end
323
+ local total = api.TakeFX_GetNumParams(take, fx)
324
+ local offset = math.max(0, int(p.offset) or 0)
325
+ local limit = int(p.limit) or total
326
+ local params = {}
327
+ for i = offset, math.min(total, offset + limit) - 1 do
328
+ local value, min, max = api.TakeFX_GetParam(take, fx, i)
329
+ local _, pname = api.TakeFX_GetParamName(take, fx, i, "")
330
+ local _, formatted = api.TakeFX_GetFormattedParamValue(take, fx, i, "")
331
+ params[#params + 1] = { paramIndex = i, name = pname or "", value = value, min = min, max = max, formatted = formatted or "" }
332
+ end
333
+ return take_fx_result(api, p, take, item, {
334
+ fxIndex = fx, params = params, paramTotal = total, hasMore = offset + #params < total,
335
+ })
336
+ end
337
+
338
+ function M.add_take_fx(api, p)
339
+ local take, item = item_take(api, p)
340
+ if not take then return nil, item end
341
+ if type(p.fxName) ~= "string" or p.fxName == "" then return nil, "fxName required" end
342
+ local added
343
+ local res, err = with_undo(api, "MCP: Add take FX '" .. p.fxName .. "'", function()
344
+ added = api.TakeFX_AddByName(take, p.fxName, -1)
345
+ if added < 0 then return nil, "FX not found: " .. p.fxName end
346
+ return true
347
+ end)
348
+ if not res then return nil, err end
349
+ local _, name = api.TakeFX_GetFXName(take, added, "")
350
+ return take_fx_result(api, p, take, item, { fxIndex = added, fxName = name })
351
+ end
352
+
353
+ function M.remove_take_fx(api, p)
354
+ local take, item = item_take(api, p)
355
+ if not take then return nil, item end
356
+ local fx, err = check_fx_index(take_fx_count(api, take), p.fxIndex, "take")
357
+ if not fx then return nil, err end
358
+ local res, e2 = with_undo(api, "MCP: Remove take FX " .. fx, function()
359
+ if not api.TakeFX_Delete(take, fx) then return nil, "Failed to remove take FX " .. fx end
360
+ return true
361
+ end)
362
+ if not res then return nil, e2 end
363
+ return take_fx_result(api, p, take, item, { removedFxIndex = fx })
364
+ end
365
+
366
+ function M.set_take_fx_parameter(api, p)
367
+ local take, item = item_take(api, p)
368
+ if not take then return nil, item end
369
+ local fx, err = check_fx_index(take_fx_count(api, take), p.fxIndex, "take")
370
+ if not fx then return nil, err end
371
+ local param = int(p.paramIndex)
372
+ local n = api.TakeFX_GetNumParams(take, fx)
373
+ if not param or param < 0 or param >= n then
374
+ return nil, "Parameter " .. tostring(p.paramIndex) .. " not found (FX has " .. n .. " parameters)"
375
+ end
376
+ local value = tonumber(p.value)
377
+ if not value then return nil, "value must be a number" end
378
+ local res, e2 = with_undo(api, "MCP: Set take FX parameter", function()
379
+ if not api.TakeFX_SetParam(take, fx, param, value) then
380
+ return nil, "Failed to set param " .. param .. " on take FX " .. fx
381
+ end
382
+ return true
383
+ end)
384
+ if not res then return nil, e2 end
385
+ local now = api.TakeFX_GetParam(take, fx, param)
386
+ local _, formatted = api.TakeFX_GetFormattedParamValue(take, fx, param, "")
387
+ return {
388
+ trackIndex = int(p.trackIndex), itemIndex = int(p.itemIndex),
389
+ takeIndex = take_index_of(api, item, take), fxIndex = fx, paramIndex = param,
390
+ value = now, formatted = formatted or "",
391
+ }
392
+ end
393
+
394
+ -- -----------------------------------------------------------------------------
395
+ -- Item color and fades
396
+ -- -----------------------------------------------------------------------------
397
+
398
+ local function parse_color(s)
399
+ if type(s) ~= "string" then return nil end
400
+ local lower = s:lower()
401
+ if lower == "none" or lower == "default" then return false end
402
+ local hex = s:match("^#?(%x%x%x%x%x%x)$")
403
+ if not hex then return nil end
404
+ return tonumber(hex:sub(1, 2), 16), tonumber(hex:sub(3, 4), 16), tonumber(hex:sub(5, 6), 16)
405
+ end
406
+
407
+ local function read_color(api, item)
408
+ local c = math.floor(api.GetMediaItemInfo_Value(item, "I_CUSTOMCOLOR") or 0)
409
+ if c & 0x1000000 == 0 then return "none" end
410
+ local r, g, b = api.ColorFromNative(c & 0xFFFFFF)
411
+ return string.format("#%02X%02X%02X", r, g, b)
412
+ end
413
+
414
+ function M.get_item_appearance(api, p)
415
+ local item, tr = get_item(api, p)
416
+ if not item then return nil, tr end
417
+ local v = function(k) return api.GetMediaItemInfo_Value(item, k) end
418
+ return {
419
+ trackIndex = int(p.trackIndex),
420
+ itemIndex = int(p.itemIndex),
421
+ color = read_color(api, item),
422
+ fadeInLength = v("D_FADEINLEN"),
423
+ fadeInShape = math.floor(v("C_FADEINSHAPE")),
424
+ fadeInCurve = v("D_FADEINDIR"),
425
+ fadeOutLength = v("D_FADEOUTLEN"),
426
+ fadeOutShape = math.floor(v("C_FADEOUTSHAPE")),
427
+ fadeOutCurve = v("D_FADEOUTDIR"),
428
+ }
429
+ end
430
+
431
+ function M.set_item_color(api, p)
432
+ local item, tr = get_item(api, p)
433
+ if not item then return nil, tr end
434
+ local r, g, b = parse_color(p.color)
435
+ if r == nil then return nil, "color must be \"#RRGGBB\" or \"none\" (got " .. tostring(p.color) .. ")" end
436
+ local res, err = with_undo(api, "MCP: Set item color", function()
437
+ local native = r and (api.ColorToNative(r, g, b) | 0x1000000) or 0
438
+ api.SetMediaItemInfo_Value(item, "I_CUSTOMCOLOR", native)
439
+ if api.UpdateItemInProject then api.UpdateItemInProject(item) end
440
+ return true
441
+ end)
442
+ if not res then return nil, err end
443
+ return M.get_item_appearance(api, p)
444
+ end
445
+
446
+ -- field -> { item key, validator }
447
+ local FADE_FIELDS = {
448
+ { "fadeInLength", "D_FADEINLEN", "length" },
449
+ { "fadeOutLength", "D_FADEOUTLEN", "length" },
450
+ { "fadeInShape", "C_FADEINSHAPE", "shape" },
451
+ { "fadeOutShape", "C_FADEOUTSHAPE", "shape" },
452
+ { "fadeInCurve", "D_FADEINDIR", "curve" },
453
+ { "fadeOutCurve", "D_FADEOUTDIR", "curve" },
454
+ }
455
+
456
+ function M.set_item_fades(api, p)
457
+ local item, tr = get_item(api, p)
458
+ if not item then return nil, tr end
459
+ local sets = {}
460
+ for _, f in ipairs(FADE_FIELDS) do
461
+ local field, key, kind = f[1], f[2], f[3]
462
+ if p[field] ~= nil then
463
+ local v = tonumber(p[field])
464
+ if kind == "length" and (not v or v < 0) then
465
+ return nil, field .. " must be a number of seconds >= 0"
466
+ elseif kind == "shape" and (not int(v) or v < 0 or v >= M.FADE_SHAPES) then
467
+ return nil, field .. " must be an integer 0-6 (0 linear, 1 fast start, 2 fast end, 3 fast start steep, 4 fast end steep, 5 slow start/end, 6 slow start/end steep)"
468
+ elseif kind == "curve" and (not v or v < -1 or v > 1) then
469
+ return nil, field .. " must be between -1 and 1"
470
+ end
471
+ sets[#sets + 1] = { key, v }
472
+ end
473
+ end
474
+ if #sets == 0 then return nil, "set at least one of fadeInLength, fadeOutLength, fadeInShape, fadeOutShape, fadeInCurve, fadeOutCurve" end
475
+ local res, err = with_undo(api, "MCP: Set item fades", function()
476
+ for _, s in ipairs(sets) do api.SetMediaItemInfo_Value(item, s[1], s[2]) end
477
+ if api.UpdateItemInProject then api.UpdateItemInProject(item) end
478
+ return true
479
+ end)
480
+ if not res then return nil, err end
481
+ return M.get_item_appearance(api, p)
482
+ end
483
+
484
+ -- -----------------------------------------------------------------------------
485
+ -- Move, copy and rename one FX
486
+ -- -----------------------------------------------------------------------------
487
+
488
+ local function transfer_fx(api, p, is_move)
489
+ local src, src_idx = get_track(api, p.trackIndex, nil, true)
490
+ if not src then return nil, src_idx end
491
+ local fx, err = check_fx_index(api.TrackFX_GetCount(src), p.fxIndex, "track " .. src_idx)
492
+ if not fx then return nil, err end
493
+ local dst, dst_idx = src, src_idx
494
+ if p.toTrackIndex ~= nil then
495
+ dst, dst_idx = get_track(api, p.toTrackIndex, "toTrackIndex", true)
496
+ if not dst then return nil, dst_idx end
497
+ end
498
+ local dst_count = api.TrackFX_GetCount(dst)
499
+ -- Moving within one track: the FX leaves the chain first, so the last slot is count-1.
500
+ local max_to = (is_move and dst == src) and dst_count - 1 or dst_count
501
+ local to = p.toIndex == nil and max_to or int(p.toIndex)
502
+ if not to or to < 0 or to > max_to then
503
+ return nil, "toIndex must be 0-" .. max_to .. " (got " .. tostring(p.toIndex) .. ")"
504
+ end
505
+ if is_move and dst == src and to == fx then
506
+ return nil, "FX " .. fx .. " is already at index " .. to
507
+ end
508
+ local verb = is_move and "Move" or "Copy"
509
+ local res, e2 = with_undo(api, "MCP: " .. verb .. " FX " .. fx, function()
510
+ api.TrackFX_CopyToTrack(src, fx, dst, to, is_move)
511
+ return true
512
+ end)
513
+ if not res then return nil, e2 end
514
+ local out = {
515
+ trackIndex = src_idx, fxIndex = fx, toTrackIndex = dst_idx, toIndex = to,
516
+ fx = track_fx_names(api, src),
517
+ }
518
+ if dst ~= src then out.toFx = track_fx_names(api, dst) end
519
+ return out
520
+ end
521
+
522
+ function M.move_fx(api, p) return transfer_fx(api, p, true) end
523
+ function M.copy_fx(api, p) return transfer_fx(api, p, false) end
524
+
525
+ function M.rename_fx(api, p)
526
+ local tr, idx = get_track(api, p.trackIndex, nil, true)
527
+ if not tr then return nil, idx end
528
+ local fx, err = check_fx_index(api.TrackFX_GetCount(tr), p.fxIndex, "track " .. idx)
529
+ if not fx then return nil, err end
530
+ if type(p.name) ~= "string" then return nil, "name required (\"\" restores the plugin's own name)" end
531
+ if not api.TrackFX_SetNamedConfigParm then return nil, "renaming FX needs REAPER 6.37 or later" end
532
+ local res, e2 = with_undo(api, "MCP: Rename FX " .. fx, function()
533
+ if not api.TrackFX_SetNamedConfigParm(tr, fx, "renamed_name", p.name) then
534
+ return nil, "REAPER refused to rename FX " .. fx
535
+ end
536
+ return true
537
+ end)
538
+ if not res then return nil, e2 end
539
+ local _, renamed = api.TrackFX_GetNamedConfigParm(tr, fx, "renamed_name")
540
+ local _, name = api.TrackFX_GetFXName(tr, fx, "")
541
+ return { trackIndex = idx, fxIndex = fx, name = name or "", renamedName = renamed or "", fx = track_fx_names(api, tr) }
542
+ end
543
+
544
+ -- -----------------------------------------------------------------------------
545
+ -- Files under REAPER's resource directory
546
+ -- -----------------------------------------------------------------------------
547
+
548
+ local function split_lines(text)
549
+ local lines = {}
550
+ for line in (text .. "\n"):gmatch("(.-)\r?\n") do lines[#lines + 1] = line end
551
+ if lines[#lines] == "" then lines[#lines] = nil end
552
+ return lines
553
+ end
554
+
555
+ local function trim(s) return (s:gsub("^%s+", ""):gsub("%s+$", "")) end
556
+ local function is_open(line) return trim(line):sub(1, 1) == "<" end
557
+ local function is_close(line) return trim(line) == ">" end
558
+
559
+ local function ends_with_ci(s, suffix)
560
+ return #s >= #suffix and s:sub(-#suffix):lower() == suffix:lower()
561
+ end
562
+
563
+ local function is_absolute(path)
564
+ return path:sub(1, 1) == "/" or path:sub(1, 1) == "\\" or path:match("^%a:[/\\]") ~= nil
565
+ end
566
+
567
+ -- A name under <resource>/<sub>/: subfolders allowed, nothing that escapes.
568
+ local function resource_path(api, sub, name, ext)
569
+ if type(name) ~= "string" or trim(name) == "" then return nil, "name required" end
570
+ if is_absolute(name) or name:match("^%a:") then
571
+ return nil, "'" .. name .. "' is outside REAPER's " .. sub .. " folder — pass a name, not a path"
572
+ end
573
+ local parts = {}
574
+ for part in name:gsub("\\", "/"):gmatch("[^/]+") do
575
+ -- Windows drops trailing dots and spaces from a path segment, so ". ." and
576
+ -- ".. " climb out too: refuse any segment made only of dots and spaces.
577
+ if part:match("^[%.%s]+$") then
578
+ return nil, "'" .. name .. "' is outside REAPER's " .. sub .. " folder — '..' is not allowed"
579
+ end
580
+ parts[#parts + 1] = part
581
+ end
582
+ local rel = table.concat(parts, "/")
583
+ if ends_with_ci(rel, ext) then rel = rel:sub(1, -#ext - 1) end
584
+ if rel == "" or rel:find("[%c]") then return nil, "invalid name '" .. name .. "'" end
585
+ local dir = api.GetResourcePath() .. "/" .. sub
586
+ local path = dir .. "/" .. rel .. ext
587
+ return path, rel, path:match("^(.*)/[^/]*$")
588
+ end
589
+
590
+ -- Reads may also name an absolute file with the right extension.
591
+ local function readable_path(api, ctx, sub, name, ext)
592
+ local path, rel
593
+ if type(name) == "string" and is_absolute(name) then
594
+ if not ends_with_ci(name, ext) then return nil, "'" .. name .. "' is not a " .. ext .. " file" end
595
+ path, rel = name, name
596
+ else
597
+ path, rel = resource_path(api, sub, name, ext)
598
+ if not path then return nil, rel end
599
+ end
600
+ if not ctx.fs.exists(path) then
601
+ -- Case-sensitive file systems: list_files shows "Lead.rfxChain" as "Lead" too.
602
+ local dir, file = path:match("^(.*)/([^/]*)$")
603
+ local stem = file and ends_with_ci(file, ext) and file:sub(1, #file - #ext)
604
+ local files = stem and ctx.fs.list(dir) or {}
605
+ for _, f in ipairs(files or {}) do
606
+ if f:sub(1, #stem) == stem and #f == #file and ends_with_ci(f, ext) then return dir .. "/" .. f, rel end
607
+ end
608
+ return nil, "file not found: " .. path
609
+ end
610
+ return path, rel
611
+ end
612
+
613
+ local function writable_path(api, ctx, sub, name, ext, overwrite)
614
+ local path, rel, dir = resource_path(api, sub, name, ext)
615
+ if not path then return nil, rel end
616
+ if ctx.fs.exists(path) and overwrite ~= true then
617
+ return nil, path .. " already exists — pass overwrite: true to replace it"
618
+ end
619
+ return path, rel, dir
620
+ end
621
+
622
+ -- Fresh GUIDs (tracks, FX, items, envelopes) so pasted state never shares an identity with its source.
623
+ local GUID_KEYS = { TRACKID = true, FXID = true, IGUID = true, GUID = true, EGUID = true }
624
+ local function regenerate_guids(chunk, gen_guid)
625
+ local lines = split_lines(chunk)
626
+ for i, line in ipairs(lines) do
627
+ local indent, key = line:match("^(%s*)(%u+)%s+%b{}%s*$")
628
+ if key and GUID_KEYS[key] then lines[i] = indent .. key .. " " .. gen_guid() end
629
+ end
630
+ return table.concat(lines, "\n") .. "\n"
631
+ end
632
+
633
+ -- -----------------------------------------------------------------------------
634
+ -- .RfxChain save / load
635
+ -- -----------------------------------------------------------------------------
636
+
637
+ local CHAIN_HEADER = { WNDRECT = true, SHOW = true, LASTSEL = true, DOCKED = true }
638
+
639
+ -- Lines inside the FXCHAIN block, without its <FXCHAIN / > and window header.
640
+ local function chain_body(fxchain_block)
641
+ local lines = split_lines(fxchain_block)
642
+ local body, started = {}, false
643
+ for i = 2, #lines - 1 do
644
+ local key = trim(lines[i]):match("^(%S+)")
645
+ if not started and not (key and CHAIN_HEADER[key]) then started = true end
646
+ if started then body[#body + 1] = lines[i] end
647
+ end
648
+ return body
649
+ end
650
+
651
+ local function chain_header(fxchain_block)
652
+ local lines = split_lines(fxchain_block)
653
+ local head = { lines[1] }
654
+ for i = 2, #lines - 1 do
655
+ local key = trim(lines[i]):match("^(%S+)")
656
+ if not (key and CHAIN_HEADER[key]) then break end
657
+ head[#head + 1] = lines[i]
658
+ end
659
+ return head
660
+ end
661
+
662
+ -- Number of FX in chain-body lines: one BYPASS line per FX, at the top level.
663
+ local function count_chain_fx(lines)
664
+ local depth, n = 0, 0
665
+ for _, line in ipairs(lines) do
666
+ if is_open(line) then depth = depth + 1
667
+ elseif is_close(line) then depth = depth - 1
668
+ elseif depth == 0 and trim(line):match("^BYPASS%s") then n = n + 1 end
669
+ end
670
+ return n
671
+ end
672
+
673
+ function M.save_fx_chain(api, p, ctx)
674
+ local tr, idx = get_track(api, p.trackIndex, nil, true)
675
+ if not tr then return nil, idx end
676
+ local count = api.TrackFX_GetCount(tr)
677
+ if count == 0 then return nil, "Track " .. idx .. " has no FX to save" end
678
+ local path, rel, dir = writable_path(api, ctx, "FXChains", p.name, M.FX_CHAIN_EXT, p.overwrite)
679
+ if not path then return nil, rel end
680
+ local _, chunk = api.GetTrackStateChunk(tr, "", false)
681
+ local block = ctx.plan.extract_fxchain(chunk or "")
682
+ if not block then return nil, "Track " .. idx .. " has no FX chain in its state chunk" end
683
+ local body = chain_body(block)
684
+ ctx.fs.mkdir(dir)
685
+ local ok, err = ctx.fs.write(path, table.concat(body, "\n") .. "\n")
686
+ if not ok then return nil, "could not write " .. path .. (err and (": " .. tostring(err)) or "") end
687
+ return { trackIndex = idx, name = rel, path = path, fxCount = count_chain_fx(body), fx = track_fx_names(api, tr) }
688
+ end
689
+
690
+ function M.load_fx_chain(api, p, ctx)
691
+ local tr, idx = get_track(api, p.trackIndex, nil, true)
692
+ if not tr then return nil, idx end
693
+ local mode = p.mode or "append"
694
+ if mode ~= "append" and mode ~= "replace" then return nil, "mode must be append or replace" end
695
+ local path, rel = readable_path(api, ctx, "FXChains", p.name, M.FX_CHAIN_EXT)
696
+ if not path then return nil, rel end
697
+ local text = ctx.fs.read(path)
698
+ if not text then return nil, "could not read " .. path end
699
+ local body = split_lines(text)
700
+ local n = count_chain_fx(body)
701
+ if n == 0 then return nil, path .. " holds no FX" end
702
+
703
+ local plan = ctx.plan
704
+ local fresh = split_lines(regenerate_guids(table.concat(body, "\n"), api.genGuid))
705
+ local _, chunk = api.GetTrackStateChunk(tr, "", false)
706
+ local existing = plan.extract_fxchain(chunk or "")
707
+ local block = {}
708
+ if existing and mode == "append" then
709
+ local lines = split_lines(existing)
710
+ for i = 1, #lines - 1 do block[#block + 1] = lines[i] end
711
+ elseif existing then
712
+ block = chain_header(existing)
713
+ else
714
+ block = { "<FXCHAIN", "SHOW 0", "LASTSEL 0", "DOCKED 0" }
715
+ end
716
+ for _, l in ipairs(fresh) do block[#block + 1] = l end
717
+ block[#block + 1] = ">"
718
+
719
+ local before = api.TrackFX_GetCount(tr)
720
+ local res, err = with_undo(api, "MCP: Load FX chain " .. rel, function()
721
+ if not api.SetTrackStateChunk(tr, plan.splice_fxchain(chunk, table.concat(block, "\n")), false) then
722
+ return nil, "REAPER rejected the FX chain"
723
+ end
724
+ return true
725
+ end)
726
+ if not res then return nil, err end
727
+ return {
728
+ trackIndex = idx, name = rel, path = path, mode = mode, loaded = n,
729
+ fxCountBefore = before, fx = track_fx_names(api, tr),
730
+ }
731
+ end
732
+
733
+ -- -----------------------------------------------------------------------------
734
+ -- Track templates
735
+ -- -----------------------------------------------------------------------------
736
+
737
+ -- Remove the direct children of the outer block that `drop(lines, i)` flags:
738
+ -- returns true to drop that line (or the whole block it opens).
739
+ local function filter_children(lines, drop)
740
+ local out, depth, skip_to = {}, 0, nil
741
+ for i, line in ipairs(lines) do
742
+ if skip_to then
743
+ if is_open(line) then depth = depth + 1 elseif is_close(line) then depth = depth - 1 end
744
+ if depth < skip_to then skip_to = nil end
745
+ else
746
+ local at_child = depth == 1
747
+ if is_open(line) then
748
+ depth = depth + 1
749
+ if at_child and drop(line) then skip_to = depth else out[#out + 1] = line end
750
+ elseif is_close(line) then
751
+ depth = depth - 1
752
+ out[#out + 1] = line
753
+ elseif not (at_child and drop(line)) then
754
+ out[#out + 1] = line
755
+ end
756
+ end
757
+ end
758
+ return out
759
+ end
760
+
761
+ function M.save_track_template(api, p, ctx)
762
+ local tr, idx = get_track(api, p.trackIndex)
763
+ if not tr then return nil, idx end
764
+ local path, rel, dir = writable_path(api, ctx, "TrackTemplates", p.name, M.TEMPLATE_EXT, p.overwrite)
765
+ if not path then return nil, rel end
766
+ local _, chunk = api.GetTrackStateChunk(tr, "", false)
767
+ if not chunk or not chunk:match("^%s*<TRACK") then return nil, "could not read track " .. idx .. "'s state" end
768
+ local keep_items = p.includeItems == true
769
+ -- A single saved track carries no receives (they point at other tracks by
770
+ -- index) and is not a folder start (that would swallow tracks below it).
771
+ local lines = filter_children(split_lines(chunk), function(line)
772
+ local key = trim(line):match("^<?(%S+)")
773
+ return key == "AUXRECV" or (key == "ITEM" and not keep_items)
774
+ end)
775
+ for i, line in ipairs(lines) do
776
+ local indent = line:match("^(%s*)ISBUS%s")
777
+ if indent then lines[i] = indent .. "ISBUS 0 0" end
778
+ end
779
+ ctx.fs.mkdir(dir)
780
+ local ok, err = ctx.fs.write(path, table.concat(lines, "\n") .. "\n")
781
+ if not ok then return nil, "could not write " .. path .. (err and (": " .. tostring(err)) or "") end
782
+ local _, name = api.GetTrackName(tr)
783
+ return {
784
+ trackIndex = idx, name = rel, path = path, trackName = name or "",
785
+ fx = track_fx_names(api, tr), includeItems = keep_items,
786
+ }
787
+ end
788
+
789
+ -- Top-level <TRACK blocks of a template file.
790
+ local function template_tracks(text)
791
+ local tracks, cur, depth = {}, nil, 0
792
+ for _, line in ipairs(split_lines(text)) do
793
+ if is_open(line) then
794
+ depth = depth + 1
795
+ if depth == 1 then
796
+ local t = trim(line)
797
+ cur = (t == "<TRACK" or t:match("^<TRACK%s")) and {} or nil
798
+ end
799
+ if cur then cur[#cur + 1] = line end
800
+ elseif is_close(line) then
801
+ if cur then cur[#cur + 1] = line end
802
+ depth = depth - 1
803
+ if depth == 0 and cur then tracks[#tracks + 1] = table.concat(cur, "\n") .. "\n"; cur = nil end
804
+ elseif cur then
805
+ cur[#cur + 1] = line
806
+ end
807
+ end
808
+ return tracks
809
+ end
810
+
811
+ function M.insert_track_template(api, p, ctx)
812
+ local path, rel = readable_path(api, ctx, "TrackTemplates", p.name, M.TEMPLATE_EXT)
813
+ if not path then return nil, rel end
814
+ local count = api.CountTracks(0)
815
+ local at = p.index == nil and count or int(p.index)
816
+ if not at or at < 0 or at > count then
817
+ return nil, "index must be 0-" .. count .. " (got " .. tostring(p.index) .. ")"
818
+ end
819
+ local text = ctx.fs.read(path)
820
+ if not text then return nil, "could not read " .. path end
821
+ local chunks = template_tracks(text)
822
+ if #chunks == 0 then return nil, path .. " holds no tracks" end
823
+
824
+ local res, err = with_undo(api, "MCP: Insert track template " .. rel, function()
825
+ local inserted = {}
826
+ for k, c in ipairs(chunks) do
827
+ local i = at + k - 1
828
+ api.InsertTrackAtIndex(i, true)
829
+ local tr = api.GetTrack(0, i)
830
+ if not api.SetTrackStateChunk(tr, regenerate_guids(c, api.genGuid), false) then
831
+ -- Take back every track this call inserted, so a refusal leaves no blank track.
832
+ for j = i, at, -1 do api.DeleteTrack(api.GetTrack(0, j)) end
833
+ return nil, "REAPER rejected track " .. k .. " of the template"
834
+ end
835
+ local _, name = api.GetTrackName(tr)
836
+ inserted[#inserted + 1] = { index = i, name = name or "", fx = track_fx_names(api, tr) }
837
+ end
838
+ return inserted
839
+ end)
840
+ if not res then return nil, err end
841
+ return { name = rel, path = path, inserted = res }
842
+ end
843
+
844
+ -- -----------------------------------------------------------------------------
845
+ -- Listing saved chains and templates
846
+ -- -----------------------------------------------------------------------------
847
+
848
+ local MAX_LIST = 500
849
+ local MAX_DEPTH = 4
850
+
851
+ local function list_files(api, ctx, sub, ext)
852
+ local root = api.GetResourcePath() .. "/" .. sub
853
+ local found = {}
854
+ local truncated = false
855
+ local function walk(dir, prefix, depth)
856
+ local files, dirs = ctx.fs.list(dir)
857
+ for _, f in ipairs(files or {}) do
858
+ if ends_with_ci(f, ext) then
859
+ if #found >= MAX_LIST then truncated = true; return end
860
+ found[#found + 1] = prefix .. f:sub(1, -#ext - 1)
861
+ end
862
+ end
863
+ if depth >= MAX_DEPTH then return end
864
+ for _, d in ipairs(dirs or {}) do
865
+ walk(dir .. "/" .. d, prefix .. d .. "/", depth + 1)
866
+ if truncated then return end
867
+ end
868
+ end
869
+ walk(root, "", 0)
870
+ table.sort(found, function(a, b) return a:lower() < b:lower() end)
871
+ return { folder = root, names = found, total = #found, truncated = truncated }
872
+ end
873
+
874
+ function M.list_fx_chains(api, _, ctx) return list_files(api, ctx, "FXChains", M.FX_CHAIN_EXT) end
875
+ function M.list_track_templates(api, _, ctx) return list_files(api, ctx, "TrackTemplates", M.TEMPLATE_EXT) end
876
+
877
+ return M