@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,748 @@
1
+ -- mcp_envelopes_lib.lua — track envelopes, their points and automation items.
2
+ --
3
+ -- Pure logic: every function takes the ReaScript API as `api`, so it runs
4
+ -- under a fake API in unit tests (envelopes-lib.test.ts). Loaded by
5
+ -- mcp_bridge.lua with dofile.
6
+ --
7
+ -- Values: a point's `value` is the raw envelope value REAPER stores. Volume
8
+ -- envelopes are fader-scaled by default (716.2 = 0 dB), so for those every
9
+ -- point also reads and writes `db`, converted with the envelope's own scaling
10
+ -- mode (ScaleToEnvelopeMode / ScaleFromEnvelopeMode).
11
+ --
12
+ -- Automation items: `automationItemIndex` (optional on every point tool)
13
+ -- addresses the points inside one automation item instead of the underlying
14
+ -- envelope. Instances of one pool share their points, so an edit to one is an
15
+ -- edit to all of them.
16
+ --
17
+ -- Every function validates all of its input before the first write, so a
18
+ -- refused request changes nothing. Writes happen inside one undo block.
19
+
20
+ local M = {}
21
+
22
+ -- Built-in envelopes REAPER creates and shows with a native action (the
23
+ -- reliable path: a hand-written chunk was rejected on some tracks). Width has
24
+ -- no such action and falls back to the chunk.
25
+ M.BUILTIN = {
26
+ ["Volume"] = { action = 40406, chunk = "VOLENV2", neutral = 1 },
27
+ ["Pan"] = { action = 40407, chunk = "PANENV2", neutral = 0 },
28
+ ["Mute"] = { action = 40867, chunk = "MUTEENV", neutral = 1 },
29
+ ["Trim Volume"] = { action = 42020, chunk = "VOLENV3", neutral = 1 },
30
+ ["Width"] = { chunk = "WIDTHENV2", neutral = 1 },
31
+ }
32
+ M.DELETE_AUTOMATION_ITEMS = 42086
33
+ M.MIN_DB = -150
34
+ M.POOL_POINTS = 0x10000000
35
+
36
+ local function int(v)
37
+ local n = tonumber(v)
38
+ if not n or n ~= math.floor(n) then return nil end
39
+ return n
40
+ end
41
+
42
+ local function with_undo(api, label, fn)
43
+ api.Undo_BeginBlock()
44
+ local ok, res, err = pcall(fn)
45
+ api.Undo_EndBlock(label, -1)
46
+ if api.UpdateArrange then api.UpdateArrange() end
47
+ if not ok then return nil, "Internal error: " .. tostring(res) end
48
+ return res, err
49
+ end
50
+
51
+ -- -----------------------------------------------------------------------------
52
+ -- Lookups (reads only)
53
+ -- -----------------------------------------------------------------------------
54
+
55
+ local function get_track(api, p)
56
+ local i = int(p.trackIndex)
57
+ if not i then return nil, "trackIndex required" end
58
+ local tr = i == -1 and api.GetMasterTrack(0) or (i >= 0 and api.GetTrack(0, i) or nil)
59
+ if not tr then return nil, "Track " .. i .. " not found" end
60
+ return tr
61
+ end
62
+
63
+ -- -> env, ai (-1 = the underlying envelope), track
64
+ function M.resolve(api, p)
65
+ local tr, err = get_track(api, p)
66
+ if not tr then return nil, err end
67
+ local ei = int(p.envelopeIndex)
68
+ if not ei then return nil, "envelopeIndex required" end
69
+ local count = api.CountTrackEnvelopes(tr)
70
+ if ei < 0 or ei >= count then
71
+ return nil, "Envelope index " .. ei .. " out of range (track has " .. count .. " envelopes)"
72
+ end
73
+ local env = api.GetTrackEnvelope(tr, ei)
74
+ local ai = -1
75
+ if p.automationItemIndex ~= nil then
76
+ ai = int(p.automationItemIndex)
77
+ local n = api.CountAutomationItems(env)
78
+ if not ai or ai < 0 or ai >= n then
79
+ return nil, "Automation item " .. tostring(p.automationItemIndex) .. " out of range (envelope has " .. n .. ")"
80
+ end
81
+ -- Point indexes over one loop iteration (the pool's own points), not every visible repeat
82
+ ai = ai | M.POOL_POINTS
83
+ end
84
+ return env, ai, tr
85
+ end
86
+
87
+ -- Volume envelopes (Volume, Volume (Pre-FX), Trim Volume, Send Volume) read in dB.
88
+ -- An FX parameter envelope is named "<param> / <fx>", so "Volume / ReaComp" is not one.
89
+ function M.is_volume(api, env)
90
+ local _, name = api.GetEnvelopeName(env)
91
+ name = name or ""
92
+ if name:find(" / ", 1, true) then return false, name end
93
+ return name:lower():find("volume", 1, true) ~= nil, name
94
+ end
95
+
96
+ local function scaling(api, env)
97
+ return api.GetEnvelopeScalingMode and api.GetEnvelopeScalingMode(env) or 0
98
+ end
99
+
100
+ function M.to_db(api, env, raw)
101
+ local amp = raw
102
+ if api.ScaleFromEnvelopeMode then amp = api.ScaleFromEnvelopeMode(scaling(api, env), raw) end
103
+ if not amp or amp <= 0 then return M.MIN_DB end
104
+ return math.max(M.MIN_DB, 20 * math.log(amp, 10))
105
+ end
106
+
107
+ function M.from_db(api, env, db)
108
+ if db <= M.MIN_DB then return 0 end
109
+ local amp = 10 ^ (db / 20)
110
+ if api.ScaleToEnvelopeMode then return api.ScaleToEnvelopeMode(scaling(api, env), amp) end
111
+ return amp
112
+ end
113
+
114
+ local function round(v, places)
115
+ local m = 10 ^ places
116
+ return math.floor(v * m + 0.5) / m
117
+ end
118
+
119
+ local function read_point(api, env, ai, i, volume)
120
+ local _, time, value, shape, tension, selected = api.GetEnvelopePointEx(env, ai, i)
121
+ local pt = { index = i, time = time, value = value, shape = shape, tension = tension, selected = selected }
122
+ if volume then pt.db = round(M.to_db(api, env, value), 2) end
123
+ return pt
124
+ end
125
+
126
+ -- A point's value from `value` or `db` (one, not both; db only on volume envelopes)
127
+ local function point_value(api, env, volume, pt, where, required)
128
+ if pt.value ~= nil and pt.db ~= nil then return nil, where .. ": give value or db, not both" end
129
+ if pt.db ~= nil then
130
+ local db = tonumber(pt.db)
131
+ if not db then return nil, where .. ": db must be a number" end
132
+ if not volume then return nil, where .. ": db only applies to volume envelopes; use value" end
133
+ return M.from_db(api, env, db)
134
+ end
135
+ if pt.value ~= nil then
136
+ local v = tonumber(pt.value)
137
+ if not v then return nil, where .. ": value must be a number" end
138
+ return v
139
+ end
140
+ if required then return nil, where .. ": value or db required" end
141
+ return nil
142
+ end
143
+
144
+ local function check_shape(pt, where)
145
+ if pt.shape ~= nil then
146
+ local s = int(pt.shape)
147
+ if not s or s < 0 or s > 5 then return nil, where .. ": shape must be 0-5" end
148
+ end
149
+ if pt.tension ~= nil then
150
+ local t = tonumber(pt.tension)
151
+ if not t or t < -1 or t > 1 then return nil, where .. ": tension must be -1..1" end
152
+ end
153
+ return true
154
+ end
155
+
156
+ local function sort(api, env, ai)
157
+ if ai >= 0 then ai = ai & ~M.POOL_POINTS end
158
+ if api.Envelope_SortPointsEx then api.Envelope_SortPointsEx(env, ai) else api.Envelope_SortPoints(env) end
159
+ end
160
+
161
+ -- REAPER keeps an automation item's points only inside its pool's own length
162
+ -- (D_POOL_QNLEN, end exclusive): a point past it is dropped from the pool with
163
+ -- no error. A new pool's length can also come out shorter than its item (49 QN
164
+ -- for a 50.25 QN item, seen live in REAPER 7.40). A point at the very end is
165
+ -- kept POOL_EDGE seconds inside it.
166
+ M.POOL_EDGE = 0.001
167
+
168
+ -- -> start, stop (project seconds) where the pool points of item `i` (plain index)
169
+ -- can sit; nil when an offset or play rate makes that mapping unclear
170
+ local function pool_span(api, env, i)
171
+ local get = function(k) return api.GetSetAutomationItemInfo(env, i, k, 0, false) end
172
+ local pos = get("D_POSITION")
173
+ if get("D_STARTOFFS") ~= 0 or get("D_PLAYRATE") ~= 1 then return nil end
174
+ local qn = api.TimeMap2_timeToQN and get("D_POOL_QNLEN")
175
+ if not qn or qn <= 0 then return pos, pos + get("D_LENGTH") end
176
+ return pos, api.TimeMap2_QNToTime(0, api.TimeMap2_timeToQN(0, pos) + qn)
177
+ end
178
+
179
+ -- A point time inside automation item `ai` (flagged or plain): the time to write,
180
+ -- or nil and why not
181
+ local function pool_time(api, env, ai, t, where)
182
+ if ai < 0 then return t end
183
+ local i = ai & ~M.POOL_POINTS
184
+ local start, stop = pool_span(api, env, i)
185
+ if not start then return t end
186
+ if t < start - 1e-9 or t > stop + 1e-9 then
187
+ return nil, string.format("%s: time %.3f is outside automation item %d (its points run %.3f..%.3f)",
188
+ where, t, i, start, stop)
189
+ end
190
+ return math.min(t, stop - M.POOL_EDGE)
191
+ end
192
+
193
+ local function target(p, ai)
194
+ local t = { trackIndex = int(p.trackIndex), envelopeIndex = int(p.envelopeIndex) }
195
+ if ai >= 0 then t.automationItemIndex = ai & ~M.POOL_POINTS end
196
+ return t
197
+ end
198
+
199
+ -- The send a send envelope belongs to (every one is named "Send Volume", "Send Pan", ...);
200
+ -- nil for any other envelope. I_SEND_IDX is 1-based, 0 when not a send.
201
+ local function send_of(api, env)
202
+ if not api.GetEnvelopeInfo_Value then return nil end
203
+ local idx = math.floor((tonumber((api.GetEnvelopeInfo_Value(env, "I_SEND_IDX"))) or 0) + 0.5)
204
+ if idx <= 0 then return nil end
205
+ local send = { index = idx - 1 }
206
+ local dest = api.GetEnvelopeInfo_Value(env, "P_DESTTRACK")
207
+ if type(dest) == "userdata" or type(dest) == "table" then
208
+ local _, name = api.GetTrackName(dest)
209
+ send.destTrackName = name
210
+ local n = math.floor(api.GetMediaTrackInfo_Value(dest, "IP_TRACKNUMBER") + 0.5)
211
+ send.destTrackIndex = n == -1 and -1 or n - 1
212
+ end
213
+ return send
214
+ end
215
+
216
+ function M.list_envelopes(api, p)
217
+ local tr, err = get_track(api, p)
218
+ if not tr then return nil, err end
219
+ local count = api.CountTrackEnvelopes(tr)
220
+ local envelopes = {}
221
+ for i = 0, count - 1 do
222
+ local env = api.GetTrackEnvelope(tr, i)
223
+ local _, name = api.GetEnvelopeName(env)
224
+ -- SWS reads the active/visible/armed state; without it these are defaults
225
+ local active, visible, armed = true, true, false
226
+ if api.BR_EnvAlloc then
227
+ local br = api.BR_EnvAlloc(env, false)
228
+ active, visible, armed = api.BR_EnvGetProperties(br)
229
+ api.BR_EnvFree(br, false)
230
+ end
231
+ envelopes[#envelopes + 1] = {
232
+ index = i, name = name, pointCount = api.CountEnvelopePoints(env),
233
+ active = active, visible = visible, armed = armed,
234
+ automationItems = api.CountAutomationItems and api.CountAutomationItems(env) or 0,
235
+ send = send_of(api, env),
236
+ }
237
+ end
238
+ return { trackIndex = int(p.trackIndex), envelopes = envelopes, count = count }
239
+ end
240
+
241
+ -- -----------------------------------------------------------------------------
242
+ -- Points
243
+ -- -----------------------------------------------------------------------------
244
+
245
+ function M.get_points(api, p)
246
+ local env, ai = M.resolve(api, p)
247
+ if not env then return nil, ai end
248
+ local volume, name = M.is_volume(api, env)
249
+ local total = api.CountEnvelopePointsEx(env, ai)
250
+ local offset = int(p.offset) or 0
251
+ local limit = int(p.limit) or total
252
+ local last = math.min(offset + limit, total)
253
+ local points = {}
254
+ for i = offset, last - 1 do points[#points + 1] = read_point(api, env, ai, i, volume) end
255
+ local r = target(p, ai)
256
+ r.name, r.unit, r.points, r.total, r.offset, r.hasMore = name, volume and "dB" or "raw", points, total, offset, last < total
257
+ return r
258
+ end
259
+
260
+ -- points: list of { time, value | db, shape?, tension? }
261
+ function M.insert_points(api, p, points)
262
+ local env, ai = M.resolve(api, p)
263
+ if not env then return nil, ai end
264
+ if type(points) ~= "table" or #points == 0 then return nil, "points must be a non-empty array" end
265
+ local volume = M.is_volume(api, env)
266
+ local todo = {}
267
+ for i, pt in ipairs(points) do
268
+ local where = "point " .. (i - 1)
269
+ local time = tonumber(pt.time)
270
+ if not time then return nil, where .. ": time required" end
271
+ local terr
272
+ time, terr = pool_time(api, env, ai, time, where)
273
+ if not time then return nil, terr end
274
+ local value, err = point_value(api, env, volume, pt, where, true)
275
+ if not value then return nil, err end
276
+ local ok, serr = check_shape(pt, where)
277
+ if not ok then return nil, serr end
278
+ todo[i] = { time, value, int(pt.shape) or 0, tonumber(pt.tension) or 0 }
279
+ end
280
+ return with_undo(api, "MCP: Insert " .. #todo .. " envelope point" .. (#todo == 1 and "" or "s"), function()
281
+ for _, t in ipairs(todo) do api.InsertEnvelopePointEx(env, ai, t[1], t[2], t[3], t[4], false, true) end
282
+ sort(api, env, ai)
283
+ local r = target(p, ai)
284
+ r.insertedPoints, r.totalPoints = #todo, api.CountEnvelopePointsEx(env, ai)
285
+ return r
286
+ end)
287
+ end
288
+
289
+ -- edits: list of { index, time?, value? | db?, shape?, tension? }; fields left out keep their value
290
+ function M.edit_points(api, p, edits)
291
+ local env, ai = M.resolve(api, p)
292
+ if not env then return nil, ai end
293
+ if type(edits) ~= "table" or #edits == 0 then return nil, "points must be a non-empty array" end
294
+ local volume = M.is_volume(api, env)
295
+ local total = api.CountEnvelopePointsEx(env, ai)
296
+ local seen, todo = {}, {}
297
+ for i, e in ipairs(edits) do
298
+ local idx = int(e.index)
299
+ local where = "edit " .. (i - 1)
300
+ if not idx or idx < 0 or idx >= total then
301
+ return nil, where .. ": point index " .. tostring(e.index) .. " out of range (envelope has " .. total .. " points)"
302
+ end
303
+ if seen[idx] then return nil, where .. ": point " .. idx .. " is edited twice" end
304
+ seen[idx] = true
305
+ if e.time ~= nil and not tonumber(e.time) then return nil, where .. ": time must be a number" end
306
+ local time = tonumber(e.time)
307
+ if time then
308
+ local terr
309
+ time, terr = pool_time(api, env, ai, time, where)
310
+ if not time then return nil, terr end
311
+ end
312
+ local value, err = point_value(api, env, volume, e, where, false)
313
+ if err then return nil, err end
314
+ local ok, serr = check_shape(e, where)
315
+ if not ok then return nil, serr end
316
+ local cur = read_point(api, env, ai, idx, false)
317
+ todo[#todo + 1] = {
318
+ idx, time or cur.time, value or cur.value,
319
+ int(e.shape) or cur.shape, tonumber(e.tension) or cur.tension, cur.selected,
320
+ }
321
+ end
322
+ return with_undo(api, "MCP: Edit " .. #todo .. " envelope point" .. (#todo == 1 and "" or "s"), function()
323
+ -- SetEnvelopePointEx ignores a flagged index (seen live, REAPER 7.40): the plain item index edits its
324
+ -- first loop iteration, which is the pool's own points
325
+ local set_ai = ai >= 0 and (ai & ~M.POOL_POINTS) or ai
326
+ for _, t in ipairs(todo) do api.SetEnvelopePointEx(env, set_ai, t[1], t[2], t[3], t[4], t[5], t[6], true) end
327
+ sort(api, env, ai)
328
+ local r = target(p, ai)
329
+ r.editedPoints, r.totalPoints = #todo, api.CountEnvelopePointsEx(env, ai)
330
+ return r
331
+ end)
332
+ end
333
+
334
+ function M.delete_point(api, p)
335
+ local env, ai = M.resolve(api, p)
336
+ if not env then return nil, ai end
337
+ local idx = int(p.pointIndex)
338
+ local total = api.CountEnvelopePointsEx(env, ai)
339
+ if not idx or idx < 0 or idx >= total then
340
+ return nil, "Point index " .. tostring(p.pointIndex) .. " out of range (envelope has " .. total .. " points)"
341
+ end
342
+ local deleted = read_point(api, env, ai, idx, M.is_volume(api, env))
343
+ return with_undo(api, "MCP: Delete envelope point", function()
344
+ api.DeleteEnvelopePointEx(env, ai, idx)
345
+ sort(api, env, ai)
346
+ local r = target(p, ai)
347
+ r.success, r.deleted, r.totalPoints = true, deleted, api.CountEnvelopePointsEx(env, ai)
348
+ return r
349
+ end)
350
+ end
351
+
352
+ -- Deletes the points with timeStart <= time < timeEnd (all of them when both are nil)
353
+ function M.remove_range(api, p, label)
354
+ local env, ai = M.resolve(api, p)
355
+ if not env then return nil, ai end
356
+ local t0, t1 = tonumber(p.timeStart), tonumber(p.timeEnd)
357
+ local all = p.timeStart == nil and p.timeEnd == nil
358
+ if not all then
359
+ if not t0 or not t1 then return nil, "timeStart and timeEnd required" end
360
+ if t1 <= t0 then return nil, "timeEnd must be after timeStart" end
361
+ end
362
+ local before = api.CountEnvelopePointsEx(env, ai)
363
+ return with_undo(api, label, function()
364
+ if all then
365
+ api.DeleteEnvelopePointRangeEx(env, ai, -math.huge, math.huge)
366
+ else
367
+ api.DeleteEnvelopePointRangeEx(env, ai, t0, t1)
368
+ end
369
+ sort(api, env, ai)
370
+ local after = api.CountEnvelopePointsEx(env, ai)
371
+ local r = target(p, ai)
372
+ if not all then r.timeStart, r.timeEnd = t0, t1 end
373
+ r.deletedPoints, r.totalPoints = before - after, after
374
+ return r
375
+ end)
376
+ end
377
+
378
+ -- -----------------------------------------------------------------------------
379
+ -- Creating built-in envelopes
380
+ -- -----------------------------------------------------------------------------
381
+
382
+ local function track_selection(api)
383
+ local sel = {}
384
+ for i = 0, api.CountTracks(0) - 1 do
385
+ local tr = api.GetTrack(0, i)
386
+ sel[#sel + 1] = { tr, api.IsTrackSelected(tr) }
387
+ end
388
+ local master = api.GetMasterTrack(0)
389
+ sel[#sel + 1] = { master, api.IsTrackSelected(master) }
390
+ return sel
391
+ end
392
+
393
+ local function restore_selection(api, sel)
394
+ for _, s in ipairs(sel) do api.SetTrackSelected(s[1], s[2]) end
395
+ end
396
+
397
+ -- REAPER drops an envelope without points, so a new one gets one neutral point at 0
398
+ local function add_by_chunk(api, tr, spec)
399
+ local _, chunk = api.GetTrackStateChunk(tr, "", false)
400
+ if chunk:find("<" .. spec.chunk .. "\n", 1, true) then
401
+ chunk = chunk:gsub("(<" .. spec.chunk .. "[^\n]*\n)ACT 0", "%1ACT 1")
402
+ chunk = chunk:gsub("(<" .. spec.chunk .. "[^\n]*\n[^\n]*\n)VIS 0", "%1VIS 1")
403
+ else
404
+ local env_chunk = "<" .. spec.chunk .. "\nACT 1 -1\nVIS 1 1 1\nLANEHEIGHT 0 0\nARM 0\nDEFSHAPE 0 -1 -1\nPT 0 "
405
+ .. spec.neutral .. " 0\n>\n"
406
+ -- Envelopes belong before the first item; else before the closing > of the track
407
+ local at = chunk:find("\n<ITEM", 1, true)
408
+ if at then
409
+ chunk = chunk:sub(1, at) .. env_chunk .. chunk:sub(at + 1)
410
+ else
411
+ local last_gt = chunk:match(".*()>")
412
+ if not last_gt then return end
413
+ chunk = chunk:sub(1, last_gt - 1) .. env_chunk .. chunk:sub(last_gt)
414
+ end
415
+ end
416
+ api.SetTrackStateChunk(tr, chunk, false)
417
+ end
418
+
419
+ local function show(api, env)
420
+ local ok, chunk = api.GetEnvelopeStateChunk(env, "", false)
421
+ if not ok or type(chunk) ~= "string" then return end
422
+ local shown = chunk:gsub("\nACT 0", "\nACT 1"):gsub("\nVIS 0", "\nVIS 1")
423
+ if shown ~= chunk then api.SetEnvelopeStateChunk(env, shown, false) end
424
+ end
425
+
426
+ local function index_of(api, tr, env)
427
+ for i = 0, api.CountTrackEnvelopes(tr) - 1 do
428
+ if api.GetTrackEnvelope(tr, i) == env then return i end
429
+ end
430
+ return -1
431
+ end
432
+
433
+ -- A send's envelope always exists (GetTrackSendInfo_Value P_ENV) but stays out of the
434
+ -- track's envelope list until it is active, visible and has a point; it starts at the
435
+ -- send's current level, pan or mute so showing it changes nothing audible.
436
+ local SEND_ENVELOPES = {
437
+ Volume = { key = "<VOLENV", field = "D_VOL" },
438
+ Pan = { key = "<PANENV", field = "D_PAN" },
439
+ Mute = { key = "<MUTEENV", field = "B_MUTE" },
440
+ }
441
+
442
+ local function create_send_envelope(api, tr, p)
443
+ local spec = SEND_ENVELOPES[p.envelopeName or ""]
444
+ if not spec then return nil, "A send envelope is Volume, Pan or Mute (got " .. tostring(p.envelopeName) .. ")" end
445
+ if p.fxIndex ~= nil then return nil, "Give sendIndex or fxIndex, not both" end
446
+ local send, sends = int(p.sendIndex), api.GetTrackNumSends(tr, 0)
447
+ if not send or send < 0 or send >= sends then
448
+ return nil, "Send index " .. tostring(p.sendIndex) .. " out of range (track has " .. sends .. " sends)"
449
+ end
450
+ local env = api.GetTrackSendInfo_Value(tr, 0, send, "P_ENV:" .. spec.key)
451
+ if not env then return nil, "REAPER returned no envelope for send " .. send end
452
+ local name = "Send " .. p.envelopeName
453
+ return with_undo(api, "MCP: Create envelope '" .. name .. "'", function()
454
+ local created = index_of(api, tr, env) < 0
455
+ local ok, chunk = api.GetEnvelopeStateChunk(env, "", false)
456
+ if not ok or type(chunk) ~= "string" then return nil, "Could not read the send envelope" end
457
+ local shown = chunk:gsub("\nACT 0", "\nACT 1"):gsub("\nVIS 0", "\nVIS 1")
458
+ if not shown:find("\nPT ", 1, true) then
459
+ local current = api.GetTrackSendInfo_Value(tr, 0, send, spec.field)
460
+ local value
461
+ if spec.field == "D_VOL" then value = M.from_db(api, env, current > 0 and 20 * math.log(current, 10) or M.MIN_DB)
462
+ elseif spec.field == "B_MUTE" then value = current ~= 0 and 0 or 1
463
+ else value = -current end -- pan envelopes store left as positive (seen live, REAPER 7.40)
464
+ shown = shown:gsub(">%s*$", "PT 0 " .. string.format("%.10g", value) .. " 0\n>")
465
+ end
466
+ if shown ~= chunk then api.SetEnvelopeStateChunk(env, shown, false) end
467
+ if api.TrackList_AdjustWindows then api.TrackList_AdjustWindows(false) end
468
+ local dest = api.GetTrackSendInfo_Value(tr, 0, send, "P_DESTTRACK")
469
+ local res = {
470
+ trackIndex = int(p.trackIndex), envelopeIndex = index_of(api, tr, env), name = name,
471
+ created = created, pointCount = api.CountEnvelopePoints(env), send = { index = send },
472
+ }
473
+ if dest then res.send.destTrackName = select(2, api.GetTrackName(dest)) end
474
+ return res
475
+ end)
476
+ end
477
+
478
+ function M.create(api, p)
479
+ local tr, err = get_track(api, p)
480
+ if not tr then return nil, err end
481
+ if p.sendIndex ~= nil then return create_send_envelope(api, tr, p) end
482
+ local name = p.envelopeName
483
+ if p.fxIndex ~= nil then
484
+ local fx, param = int(p.fxIndex), int(p.paramIndex)
485
+ if not param then return nil, "paramIndex is required when fxIndex is provided" end
486
+ local fx_count = api.TrackFX_GetCount(tr)
487
+ if not fx or fx < 0 or fx >= fx_count then
488
+ return nil, "FX index " .. tostring(p.fxIndex) .. " out of range (track has " .. fx_count .. " FX)"
489
+ end
490
+ local param_count = api.TrackFX_GetNumParams(tr, fx)
491
+ if param < 0 or param >= param_count then
492
+ return nil, "Parameter index " .. param .. " out of range (FX has " .. param_count .. " parameters)"
493
+ end
494
+ elseif name then
495
+ if not M.BUILTIN[name] then
496
+ return nil, "Unknown envelope name: " .. tostring(name) .. ". Use Volume, Pan, Mute, Width, or Trim Volume"
497
+ end
498
+ else
499
+ return nil, "Must provide either envelopeName or fxIndex+paramIndex"
500
+ end
501
+
502
+ local label = "MCP: Create envelope '" .. (name or "FX parameter") .. "'"
503
+ return with_undo(api, label, function()
504
+ local env, created = nil, false
505
+ if p.fxIndex ~= nil then
506
+ created = api.GetFXEnvelope(tr, p.fxIndex, p.paramIndex, false) == nil
507
+ env = api.GetFXEnvelope(tr, p.fxIndex, p.paramIndex, true)
508
+ if not env then return nil, "Failed to create FX parameter envelope (fxIndex=" .. p.fxIndex .. ", paramIndex=" .. p.paramIndex .. ")" end
509
+ local _, pname = api.TrackFX_GetParamName(tr, p.fxIndex, p.paramIndex)
510
+ local _, fname = api.TrackFX_GetFXName(tr, p.fxIndex)
511
+ name = fname .. " / " .. pname
512
+ else
513
+ local spec = M.BUILTIN[name]
514
+ env = api.GetTrackEnvelopeByName(tr, name)
515
+ if not env then
516
+ created = true
517
+ if spec.action then
518
+ -- The action works on the selected tracks: select only this one, then put the selection back
519
+ local sel = track_selection(api)
520
+ api.SetOnlyTrackSelected(tr)
521
+ api.Main_OnCommand(spec.action, 0)
522
+ restore_selection(api, sel)
523
+ env = api.GetTrackEnvelopeByName(tr, name)
524
+ end
525
+ if not env then
526
+ add_by_chunk(api, tr, spec)
527
+ env = api.GetTrackEnvelopeByName(tr, name)
528
+ end
529
+ if not env then return nil, "Failed to create envelope: " .. name end
530
+ end
531
+ end
532
+ show(api, env)
533
+ if api.TrackList_AdjustWindows then api.TrackList_AdjustWindows(false) end
534
+ return {
535
+ trackIndex = int(p.trackIndex),
536
+ envelopeIndex = index_of(api, tr, env),
537
+ name = name,
538
+ created = created,
539
+ pointCount = api.CountEnvelopePoints(env),
540
+ }
541
+ end)
542
+ end
543
+
544
+ -- -----------------------------------------------------------------------------
545
+ -- Automation items
546
+ -- -----------------------------------------------------------------------------
547
+
548
+ local AI_FIELDS = {
549
+ position = "D_POSITION", length = "D_LENGTH", startOffset = "D_STARTOFFS", playRate = "D_PLAYRATE",
550
+ baseline = "D_BASELINE", amplitude = "D_AMPLITUDE",
551
+ }
552
+
553
+ local function ai_info(api, env, i)
554
+ local function get(parm) return api.GetSetAutomationItemInfo(env, i, parm, 0, false) end
555
+ local _, pool_name = api.GetSetAutomationItemInfo_String(env, i, "P_POOL_NAME", "", false)
556
+ local pool = math.floor(get("D_POOL_ID") + 0.5)
557
+ return {
558
+ index = i,
559
+ poolId = pool,
560
+ name = pool_name or "",
561
+ position = get("D_POSITION"),
562
+ length = get("D_LENGTH"),
563
+ startOffset = get("D_STARTOFFS"),
564
+ playRate = get("D_PLAYRATE"),
565
+ baseline = get("D_BASELINE"),
566
+ amplitude = get("D_AMPLITUDE"),
567
+ loop = get("D_LOOPSRC") ~= 0,
568
+ selected = get("D_UISEL") ~= 0,
569
+ pointCount = api.CountEnvelopePointsEx(env, i | M.POOL_POINTS),
570
+ }
571
+ end
572
+
573
+ -- Every automation item in the project, for pool ids and for selection
574
+ local function each_automation_item(api, fn)
575
+ local function track_envs(tr)
576
+ for e = 0, api.CountTrackEnvelopes(tr) - 1 do
577
+ local env = api.GetTrackEnvelope(tr, e)
578
+ for i = 0, api.CountAutomationItems(env) - 1 do fn(env, i) end
579
+ end
580
+ end
581
+ track_envs(api.GetMasterTrack(0))
582
+ for t = 0, api.CountTracks(0) - 1 do track_envs(api.GetTrack(0, t)) end
583
+ end
584
+
585
+ -- Instances of a pool anywhere in the project: pools are shared across tracks and envelopes
586
+ local function pool_instances(api, pool)
587
+ local n = 0
588
+ each_automation_item(api, function(e, i)
589
+ if math.floor(api.GetSetAutomationItemInfo(e, i, "D_POOL_ID", 0, false) + 0.5) == pool then n = n + 1 end
590
+ end)
591
+ return n
592
+ end
593
+
594
+ function M.list_items(api, p)
595
+ local env, err = M.resolve(api, p)
596
+ if not env then return nil, err end
597
+ local items = {}
598
+ for i = 0, api.CountAutomationItems(env) - 1 do items[#items + 1] = ai_info(api, env, i) end
599
+ local _, name = M.is_volume(api, env)
600
+ local r = target(p, -1)
601
+ r.name, r.items, r.count = name, items, #items
602
+ return r
603
+ end
604
+
605
+ local function check_item_fields(p)
606
+ for key in pairs(AI_FIELDS) do
607
+ if p[key] ~= nil and not tonumber(p[key]) then return nil, key .. " must be a number" end
608
+ end
609
+ if p.length ~= nil and tonumber(p.length) <= 0 then return nil, "length must be > 0" end
610
+ if p.position ~= nil and tonumber(p.position) < 0 then return nil, "position must be >= 0" end
611
+ if p.playRate ~= nil and tonumber(p.playRate) <= 0 then return nil, "playRate must be > 0" end
612
+ if p.baseline ~= nil and (tonumber(p.baseline) < 0 or tonumber(p.baseline) > 1) then return nil, "baseline must be 0..1" end
613
+ if p.amplitude ~= nil and (tonumber(p.amplitude) < -1 or tonumber(p.amplitude) > 1) then return nil, "amplitude must be -1..1" end
614
+ if p.name ~= nil and type(p.name) ~= "string" then return nil, "name must be a string" end
615
+ return true
616
+ end
617
+
618
+ local function apply_item_fields(api, env, i, p)
619
+ for key, parm in pairs(AI_FIELDS) do
620
+ if p[key] ~= nil and key ~= "position" and key ~= "length" then
621
+ api.GetSetAutomationItemInfo(env, i, parm, tonumber(p[key]), true)
622
+ end
623
+ end
624
+ if p.loop ~= nil then api.GetSetAutomationItemInfo(env, i, "D_LOOPSRC", p.loop and 1 or 0, true) end
625
+ if p.name ~= nil then api.GetSetAutomationItemInfo_String(env, i, "P_POOL_NAME", p.name, true) end
626
+ end
627
+
628
+ -- New automation item at position/length. poolId: an existing pool makes a pooled
629
+ -- instance (same points); omitted makes a new empty pool, filled from `points`
630
+ -- (times relative to the item start). captureEnvelope moves the underlying points
631
+ -- in that range into the new item instead.
632
+ function M.insert_item(api, p, points)
633
+ local env, err = M.resolve(api, { trackIndex = p.trackIndex, envelopeIndex = p.envelopeIndex })
634
+ if not env then return nil, err end
635
+ local pos, len = tonumber(p.position), tonumber(p.length)
636
+ if not pos or pos < 0 then return nil, "position (seconds, >= 0) required" end
637
+ if not len or len <= 0 then return nil, "length (seconds, > 0) required" end
638
+ local ok, ferr = check_item_fields(p)
639
+ if not ok then return nil, ferr end
640
+ local pool = p.poolId ~= nil and int(p.poolId) or nil
641
+ if p.poolId ~= nil and (not pool or pool < 0) then return nil, "poolId must be an integer >= 0" end
642
+ if p.captureEnvelope and pool then return nil, "captureEnvelope makes a new pool; leave out poolId" end
643
+ local existing = false
644
+ if pool then
645
+ each_automation_item(api, function(e, i)
646
+ if math.floor(api.GetSetAutomationItemInfo(e, i, "D_POOL_ID", 0, false) + 0.5) == pool then existing = true end
647
+ end)
648
+ end
649
+ if existing and points then return nil, "points only apply to a new pool; edit the pool's points with edit_envelope_points" end
650
+ local volume = M.is_volume(api, env)
651
+ local todo = {}
652
+ for i, pt in ipairs(points or {}) do
653
+ local where = "point " .. (i - 1)
654
+ local t = tonumber(pt.time)
655
+ if not t or t < 0 or t > len then return nil, where .. ": time must be 0.." .. len .. " (seconds from the item start)" end
656
+ local value, verr = point_value(api, env, volume, pt, where, true)
657
+ if not value then return nil, verr end
658
+ local sok, serr = check_shape(pt, where)
659
+ if not sok then return nil, serr end
660
+ todo[#todo + 1] = { pos + t, value, int(pt.shape) or 0, tonumber(pt.tension) or 0 }
661
+ end
662
+ if not pool and not p.captureEnvelope then
663
+ local max = -1
664
+ each_automation_item(api, function(e, i)
665
+ max = math.max(max, math.floor(api.GetSetAutomationItemInfo(e, i, "D_POOL_ID", 0, false) + 0.5))
666
+ end)
667
+ pool = max + 1
668
+ end
669
+
670
+ return with_undo(api, "MCP: Insert automation item", function()
671
+ local idx = api.InsertAutomationItem(env, p.captureEnvelope and -1 or pool, pos, len)
672
+ if idx == nil or idx < 0 then return nil, "REAPER refused to insert the automation item" end
673
+ if not existing and api.TimeMap2_timeToQN then
674
+ -- The pool spans the whole item, or points near its end are dropped
675
+ api.GetSetAutomationItemInfo(env, idx, "D_POOL_QNLEN",
676
+ api.TimeMap2_timeToQN(0, pos + len) - api.TimeMap2_timeToQN(0, pos), true)
677
+ end
678
+ if #todo > 0 then
679
+ -- A new pool's item can start with points of its own; replace them with ours
680
+ local pidx = idx | M.POOL_POINTS
681
+ api.DeleteEnvelopePointRangeEx(env, pidx, -math.huge, math.huge)
682
+ for _, t in ipairs(todo) do
683
+ api.InsertEnvelopePointEx(env, pidx, (pool_time(api, env, pidx, t[1], "")), t[2], t[3], t[4], false, true)
684
+ end
685
+ sort(api, env, pidx)
686
+ end
687
+ apply_item_fields(api, env, idx, p)
688
+ local r = target(p, -1)
689
+ r.automationItem = ai_info(api, env, idx)
690
+ r.pooledWith = pool_instances(api, r.automationItem.poolId) - 1
691
+ return r
692
+ end)
693
+ end
694
+
695
+ -- The item itself, not its points: the plain index without the pool-points flag
696
+ local function resolve_item(api, p)
697
+ if p.automationItemIndex == nil then return nil, "automationItemIndex required" end
698
+ local env, ai = M.resolve(api, p)
699
+ if not env then return nil, ai end
700
+ return env, ai & ~M.POOL_POINTS
701
+ end
702
+
703
+ function M.set_item(api, p)
704
+ local env, ai = resolve_item(api, p)
705
+ if not env then return nil, ai end
706
+ local ok, ferr = check_item_fields(p)
707
+ if not ok then return nil, ferr end
708
+ return with_undo(api, "MCP: Edit automation item", function()
709
+ if p.position ~= nil then api.GetSetAutomationItemInfo(env, ai, "D_POSITION", tonumber(p.position), true) end
710
+ if p.length ~= nil then api.GetSetAutomationItemInfo(env, ai, "D_LENGTH", tonumber(p.length), true) end
711
+ apply_item_fields(api, env, ai, p)
712
+ local r = target(p, -1)
713
+ r.automationItem = ai_info(api, env, ai)
714
+ return r
715
+ end)
716
+ end
717
+
718
+ -- Deletes one automation item (its points go with it unless preservePoints).
719
+ -- REAPER has no API call for it: select only this item, run the delete action,
720
+ -- then put every other item's selection back.
721
+ function M.delete_item(api, p)
722
+ local env, ai = resolve_item(api, p)
723
+ if not env then return nil, ai end
724
+ local deleted = ai_info(api, env, ai)
725
+ return with_undo(api, "MCP: Delete automation item", function()
726
+ local sel = {}
727
+ each_automation_item(api, function(e, i)
728
+ local was = api.GetSetAutomationItemInfo(e, i, "D_UISEL", 0, false) ~= 0
729
+ local this = e == env and i == ai
730
+ if not this then sel[#sel + 1] = { e, i, was } end
731
+ api.GetSetAutomationItemInfo(e, i, "D_UISEL", this and 1 or 0, true)
732
+ end)
733
+ local before = api.CountAutomationItems(env)
734
+ api.Main_OnCommand(p.preservePoints and 42088 or M.DELETE_AUTOMATION_ITEMS, 0)
735
+ -- Items after the deleted one on this envelope moved down one index
736
+ for _, s in ipairs(sel) do
737
+ local i = s[2]
738
+ if s[1] == env and i > ai then i = i - 1 end
739
+ api.GetSetAutomationItemInfo(s[1], i, "D_UISEL", s[3] and 1 or 0, true)
740
+ end
741
+ if api.CountAutomationItems(env) ~= before - 1 then return nil, "Internal error: the automation item was not deleted" end
742
+ local r = target(p, -1)
743
+ r.deleted, r.count = deleted, api.CountAutomationItems(env)
744
+ return r
745
+ end)
746
+ end
747
+
748
+ return M