@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
|
@@ -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
|