@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,949 @@
1
+ -- mcp_arrange_lib.lua — arrangement edits: copy items, insert and remove time
2
+ -- across the project, copy a section, and process items (normalize, reverse,
3
+ -- glue).
4
+ --
5
+ -- Pure logic over an injected ReaScript API, unit-tested via wasmoon against
6
+ -- a fake. Loaded by mcp_bridge.lua, and used by mcp_plan_lib.lua's run_plan
7
+ -- ops through check_op / apply_op.
8
+ --
9
+ -- Every function checks all of its input before the first write, so a refused
10
+ -- request changes nothing; each tool call is one undo point. Time edits are
11
+ -- done here rather than with REAPER's ripple actions, so the result does not
12
+ -- depend on the user's ripple and "move envelope points with items" options.
13
+
14
+ local M = {}
15
+
16
+ M.MAX_ITEMS = 64 -- items one call may name
17
+ M.MAX_COPIES = 256 -- items one copy may create
18
+ M.MAX_REPEAT = 64
19
+ M.MAX_SPAN = 3600 -- seconds one insert/remove/copy may span
20
+ M.MAX_LISTED = 64 -- items listed in a response; the rest are counted
21
+ M.REVERSE = 41051 -- Item properties: Toggle take reverse
22
+ M.GLUE = 40362 -- Item: Glue items, ignoring time selection (41588 glues only inside it)
23
+ M.DELETE_AUTOMATION_ITEMS = 42086
24
+ -- CalculateNormalization modes
25
+ M.NORMALIZE_MODES = { lufs_i = 0, rms = 1, peak = 2, true_peak = 3, lufs_m_max = 4, lufs_s_max = 5 }
26
+ local EPS = 1e-6
27
+ local PIN = 0.001 -- seconds between a copied section's edge and the point keeping the destination's value
28
+
29
+ local function num(v) return type(v) == "number" and v == v and v ~= math.huge and v ~= -math.huge end
30
+ local function int(v) return num(v) and v == math.floor(v) end
31
+ local function r3(v) return math.floor(v * 1000 + 0.5) / 1000 end
32
+ local function db(g) return g > 0 and r3(20 * math.log(g, 10)) or -150 end
33
+
34
+ local function with_undo(api, label, fn)
35
+ api.Undo_BeginBlock()
36
+ if api.PreventUIRefresh then api.PreventUIRefresh(1) end
37
+ local ok, res = pcall(fn)
38
+ if api.PreventUIRefresh then api.PreventUIRefresh(-1) end
39
+ -- A failure half way leaves no partial edit. The failed block gets its own label, so a failure
40
+ -- that wrote nothing (no undo point) never undoes the user's last edit, even one by this tool
41
+ local revert = label .. " (failed — reverting)"
42
+ api.Undo_EndBlock(ok and label or revert, -1)
43
+ local undone = not ok and api.Undo_CanUndo2 and api.Undo_CanUndo2(0) == revert
44
+ if undone then api.Undo_DoUndo2(0) end
45
+ if api.UpdateTimeline then api.UpdateTimeline() end
46
+ if api.UpdateArrange then api.UpdateArrange() end
47
+ if not ok then return nil, tostring(res) .. (undone and " — the edit was undone" or "") end
48
+ return res
49
+ end
50
+
51
+ -- -----------------------------------------------------------------------------
52
+ -- Lookups (reads only)
53
+ -- -----------------------------------------------------------------------------
54
+
55
+ local function track_at(api, idx, field)
56
+ field = field or "trackIndex"
57
+ if not int(idx) or idx < 0 then return nil, field .. " must be a track index (0 or more)" end
58
+ local tr = api.GetTrack(0, idx)
59
+ if not tr then return nil, field .. ": track " .. idx .. " not found" end
60
+ return tr
61
+ end
62
+
63
+ local function item_pos(api, it) return api.GetMediaItemInfo_Value(it, "D_POSITION") end
64
+ local function item_len(api, it) return api.GetMediaItemInfo_Value(it, "D_LENGTH") end
65
+
66
+ -- items: [{ trackIndex, itemIndex }] → { item, track, trackIndex, itemIndex, pos, len }, all checked
67
+ local function resolve_items(api, list)
68
+ if type(list) ~= "table" or #list == 0 then return nil, "items must be a non-empty list of { trackIndex, itemIndex }" end
69
+ if #list > M.MAX_ITEMS then return nil, "at most " .. M.MAX_ITEMS .. " items per call" end
70
+ local out, seen = {}, {}
71
+ for k, ref in ipairs(list) do
72
+ local at = "items[" .. (k - 1) .. "]"
73
+ if type(ref) ~= "table" then return nil, at .. " must be { trackIndex, itemIndex }" end
74
+ local tr, err = track_at(api, ref.trackIndex)
75
+ if not tr then return nil, at .. ": " .. err end
76
+ if not int(ref.itemIndex) or ref.itemIndex < 0 then return nil, at .. ": itemIndex must be 0 or more" end
77
+ local it = api.GetTrackMediaItem(tr, ref.itemIndex)
78
+ if not it then return nil, at .. ": track " .. ref.trackIndex .. " has no item " .. ref.itemIndex end
79
+ if seen[it] then return nil, at .. " names the same item twice" end
80
+ seen[it] = true
81
+ out[#out + 1] = { item = it, track = tr, trackIndex = ref.trackIndex, itemIndex = ref.itemIndex,
82
+ pos = item_pos(api, it), len = item_len(api, it) }
83
+ end
84
+ return out
85
+ end
86
+
87
+ local function track_index(api, tr) return math.floor(api.GetMediaTrackInfo_Value(tr, "IP_TRACKNUMBER")) - 1 end
88
+
89
+ -- Where an item sits now, as the media tools address it
90
+ local function describe(api, it)
91
+ local tr = api.GetMediaItem_Track(it)
92
+ local idx
93
+ for i = 0, api.CountTrackMediaItems(tr) - 1 do
94
+ if api.GetTrackMediaItem(tr, i) == it then idx = i break end
95
+ end
96
+ return { trackIndex = track_index(api, tr), itemIndex = idx, position = r3(item_pos(api, it)), length = r3(item_len(api, it)) }
97
+ end
98
+
99
+ local function describe_all(api, items)
100
+ local out = {}
101
+ for i = 1, math.min(#items, M.MAX_LISTED) do out[i] = describe(api, items[i]) end
102
+ return out
103
+ end
104
+
105
+ local function active_take_is_midi(api, it)
106
+ local take = api.GetActiveTake(it)
107
+ return take ~= nil and api.TakeIsMIDI(take)
108
+ end
109
+
110
+ -- Every track envelope in the project, the master's included, except the master's "Tempo map":
111
+ -- its points are the tempo markers, which the time edits change through the tempo API
112
+ local function each_envelope(api, fn)
113
+ local master = api.GetMasterTrack(0)
114
+ local function track_envs(tr)
115
+ for i = 0, (api.CountTrackEnvelopes(tr) or 0) - 1 do
116
+ local env = api.GetTrackEnvelope(tr, i)
117
+ local _, name = api.GetEnvelopeName(env)
118
+ if not (tr == master and name == "Tempo map") then fn(env, tr) end
119
+ end
120
+ end
121
+ track_envs(master)
122
+ for t = 0, api.CountTracks(0) - 1 do track_envs(api.GetTrack(0, t)) end
123
+ end
124
+
125
+ -- Split at `t` without the overlap REAPER's auto-crossfade option adds (it lengthens the left
126
+ -- part past the split): the left part ends at `t` and neither part gets a crossfade
127
+ local function split_at(api, it, t)
128
+ local right = api.SplitMediaItem(it, t)
129
+ if not right then return nil end
130
+ api.SetMediaItemInfo_Value(it, "D_LENGTH", t - api.GetMediaItemInfo_Value(it, "D_POSITION"))
131
+ api.SetMediaItemInfo_Value(it, "D_FADEOUTLEN", 0)
132
+ api.SetMediaItemInfo_Value(it, "D_FADEOUTLEN_AUTO", 0)
133
+ api.SetMediaItemInfo_Value(right, "D_FADEINLEN", 0)
134
+ api.SetMediaItemInfo_Value(right, "D_FADEINLEN_AUTO", 0)
135
+ return right
136
+ end
137
+
138
+ local function point(api, env, i)
139
+ local _, t, v, shape, tension, sel = api.GetEnvelopePoint(env, i)
140
+ return { t = t, v = v, shape = shape, tension = tension, sel = sel }
141
+ end
142
+
143
+ local function markers(api)
144
+ local out = {}
145
+ local _, nm, nr = api.CountProjectMarkers(0)
146
+ for i = 0, (nm or 0) + (nr or 0) - 1 do
147
+ local _, rgn, pos, rend, name, idx, color = api.EnumProjectMarkers3(0, i)
148
+ out[#out + 1] = { rgn = rgn, pos = pos, rend = rend, name = name or "", num = idx, color = color or 0 }
149
+ end
150
+ return out
151
+ end
152
+
153
+ local function tempo_markers(api)
154
+ local out = {}
155
+ for i = 0, api.CountTempoTimeSigMarkers(0) - 1 do
156
+ local _, t, _, _, bpm, n, d, lin = api.GetTempoTimeSigMarker(0, i)
157
+ out[#out + 1] = { t = t, bpm = bpm, num = n, den = d, linear = lin }
158
+ end
159
+ return out
160
+ end
161
+
162
+ -- The tempo at t: a linear marker ramps to the next one
163
+ local function bpm_at(tempo, t)
164
+ local prev
165
+ for i, m in ipairs(tempo) do if m.t <= t + EPS then prev = i end end
166
+ if not prev then return tempo[1] and tempo[1].bpm end
167
+ local m, n = tempo[prev], tempo[prev + 1]
168
+ if m.linear and n and n.t > m.t + EPS then return m.bpm + (n.bpm - m.bpm) * (t - m.t) / (n.t - m.t) end
169
+ return m.bpm
170
+ end
171
+
172
+ -- The linear marker whose ramp runs across t (starts before it, its next marker after it)
173
+ local function ramp_across(tempo, t)
174
+ for i, m in ipairs(tempo) do
175
+ local n = tempo[i + 1]
176
+ if m.linear and m.t < t - EPS and n and n.t > t + EPS then return m end
177
+ end
178
+ end
179
+
180
+ -- A new tempo marker (0 = no time signature change)
181
+ local function add_tempo(api, t, bpm, linear)
182
+ api.SetTempoTimeSigMarker(0, -1, t, -1, -1, bpm, 0, 0, linear)
183
+ end
184
+
185
+ -- Selected items project-wide, so an action that works on the selection can be pointed at
186
+ -- a few items and the user's selection put back afterwards
187
+ local function save_item_selection(api)
188
+ local sel = {}
189
+ for i = 0, api.CountMediaItems(0) - 1 do
190
+ local it = api.GetMediaItem(0, i)
191
+ if api.IsMediaItemSelected(it) then sel[#sel + 1] = it end
192
+ end
193
+ return sel
194
+ end
195
+
196
+ local function restore_item_selection(api, sel)
197
+ api.SelectAllMediaItems(0, false)
198
+ for _, it in ipairs(sel) do
199
+ if api.ValidatePtr2(0, it, "MediaItem*") then api.SetMediaItemSelected(it, true) end
200
+ end
201
+ end
202
+
203
+ -- -----------------------------------------------------------------------------
204
+ -- Copy items
205
+ -- -----------------------------------------------------------------------------
206
+
207
+ -- An item's state with fresh item, take and FX GUIDs. MIDI gets a fresh pool id unless
208
+ -- `pooled`, so editing a copy never edits its source.
209
+ local GUID_KEYS = { IGUID = true, GUID = true, FXID = true }
210
+ local function fresh_chunk(api, chunk, pooled)
211
+ return (chunk:gsub("([\r\n]%s*)(%u+)(%s+){[%x%-]+}", function(pre, key, sp)
212
+ if GUID_KEYS[key] or (key == "POOLEDEVTS" and not pooled) then return pre .. key .. sp .. api.genGuid("") end
213
+ end))
214
+ end
215
+
216
+ local function copy_item(api, src, tr, pos, pooled)
217
+ local ok, chunk = api.GetItemStateChunk(src, "", false)
218
+ if not ok or type(chunk) ~= "string" then error("could not read an item's state") end
219
+ local it = api.AddMediaItemToTrack(tr)
220
+ api.SetItemStateChunk(it, fresh_chunk(api, chunk, pooled), false)
221
+ api.SetMediaItemInfo_Value(it, "D_POSITION", pos)
222
+ api.SetMediaItemSelected(it, false)
223
+ return it
224
+ end
225
+
226
+ local function check_copy(api, p)
227
+ local items, err = resolve_items(api, p.items)
228
+ if not items then return nil, err end
229
+ local times = p.times == nil and 1 or p.times
230
+ if not int(times) or times < 1 or times > M.MAX_REPEAT then return nil, "times must be 1 to " .. M.MAX_REPEAT end
231
+ if #items * times > M.MAX_COPIES then return nil, "at most " .. M.MAX_COPIES .. " copies per call (" .. #items .. " items × " .. times .. ")" end
232
+ local first, last = math.huge, -math.huge
233
+ for _, s in ipairs(items) do
234
+ first = math.min(first, s.pos)
235
+ last = math.max(last, s.pos + s.len)
236
+ end
237
+ local position = p.position == nil and last or p.position
238
+ if not num(position) or position < 0 then return nil, "position must be 0 or more seconds" end
239
+ local dest
240
+ if p.trackIndex ~= nil then
241
+ dest, err = track_at(api, p.trackIndex)
242
+ if not dest then return nil, err end
243
+ end
244
+ if p.pooled ~= nil and type(p.pooled) ~= "boolean" then return nil, "pooled must be true or false" end
245
+ return { items = items, times = times, first = first, span = last - first, position = position, dest = dest, pooled = p.pooled == true }
246
+ end
247
+
248
+ local function apply_copy(api, c)
249
+ local made = {}
250
+ for k = 0, c.times - 1 do
251
+ local base = c.position + k * c.span
252
+ for _, s in ipairs(c.items) do
253
+ made[#made + 1] = copy_item(api, s.item, c.dest or s.track, base + (s.pos - c.first), c.pooled)
254
+ end
255
+ end
256
+ return { copies = describe_all(api, made), count = #made, span = r3(c.span), times = c.times }
257
+ end
258
+
259
+ function M.copy_items(api, p)
260
+ local c, err = check_copy(api, p)
261
+ if not c then return nil, err .. " — nothing changed" end
262
+ return with_undo(api, "MCP: Copy items", function() return apply_copy(api, c) end)
263
+ end
264
+
265
+ -- -----------------------------------------------------------------------------
266
+ -- Insert / remove time
267
+ -- -----------------------------------------------------------------------------
268
+
269
+ -- Tempo markers move first, then markers, envelopes and items are set to absolute times
270
+ -- read before any change: items and markers on a beat timebase would otherwise follow the
271
+ -- tempo change on their own.
272
+
273
+ -- Everything a time edit moves, read before the tempo map changes: with REAPER's default
274
+ -- beat timebase, markers, envelope points and items after a moved tempo marker move with it
275
+ local function snapshot(api)
276
+ local snap = { markers = markers(api), envs = {}, items = {} }
277
+ each_envelope(api, function(env)
278
+ local e = { env = env, pts = {}, ais = {} }
279
+ for i = 0, api.CountEnvelopePoints(env) - 1 do e.pts[#e.pts + 1] = point(api, env, i) end
280
+ for i = 0, api.CountAutomationItems(env) - 1 do
281
+ e.ais[#e.ais + 1] = { pos = api.GetSetAutomationItemInfo(env, i, "D_POSITION", 0, false),
282
+ len = api.GetSetAutomationItemInfo(env, i, "D_LENGTH", 0, false) }
283
+ end
284
+ snap.envs[#snap.envs + 1] = e
285
+ end)
286
+ for t = 0, api.CountTracks(0) - 1 do
287
+ local tr = api.GetTrack(0, t)
288
+ for i = 0, api.CountTrackMediaItems(tr) - 1 do
289
+ local it = api.GetTrackMediaItem(tr, i)
290
+ snap.items[#snap.items + 1] = { it = it, tr = tr, pos = item_pos(api, it), len = item_len(api, it) }
291
+ end
292
+ end
293
+ return snap
294
+ end
295
+
296
+ -- REAPER re-times beat-timebase content against a changed tempo map lazily; do it now, so
297
+ -- what is set next from the snapshot is converted with the new map
298
+ local function retime(api)
299
+ if api.UpdateTimeline then api.UpdateTimeline() end
300
+ end
301
+
302
+ -- Value of an envelope at t, read before anything changes it
303
+ local function value_at(api, env, t)
304
+ local _, v = api.Envelope_Evaluate(env, t, 0, 0)
305
+ return v
306
+ end
307
+
308
+ -- True when an envelope has a point before t / at or after t
309
+ local function has_before(pts, t)
310
+ return pts[1] ~= nil and pts[1].t < t - EPS
311
+ end
312
+ local function has_from(pts, t)
313
+ return pts[#pts] ~= nil and pts[#pts].t >= t - EPS
314
+ end
315
+ -- Shape and tension of the segment running through t: the last point before it (linear when none)
316
+ local function shape_through(pts, t)
317
+ local shape, tension = 0, 0
318
+ for _, pt in ipairs(pts) do
319
+ if pt.t < t - EPS then shape, tension = pt.shape, pt.tension end
320
+ end
321
+ return shape, tension
322
+ end
323
+
324
+ local function has_at(pts, t)
325
+ for _, pt in ipairs(pts) do if math.abs(pt.t - t) <= EPS then return true end end
326
+ return false
327
+ end
328
+
329
+ local function check_insert(api, p)
330
+ if not num(p.position) or p.position < 0 then return nil, "position must be 0 or more seconds" end
331
+ if not num(p.length) or p.length <= 0 or p.length > M.MAX_SPAN then return nil, "length must be more than 0 and at most " .. M.MAX_SPAN .. " seconds" end
332
+ if p.splitItems ~= nil and type(p.splitItems) ~= "boolean" then return nil, "splitItems must be true or false" end
333
+ return { at = p.position, len = p.length, split = p.splitItems ~= false }
334
+ end
335
+
336
+ local function apply_insert(api, c)
337
+ local at, len = c.at, c.len
338
+ local r = { position = r3(at), length = r3(len), itemsMoved = 0, itemsSplit = 0, markersMoved = 0, regionsExtended = 0,
339
+ tempoMarkersMoved = 0, envelopePointsMoved = 0, automationItemsMoved = 0, automationItemsSpanning = 0 }
340
+ local snap = snapshot(api)
341
+ -- The value at the position, held across the opened time so the segment spanning it is not stretched
342
+ for _, e in ipairs(snap.envs) do
343
+ if has_before(e.pts, at) and has_from(e.pts, at) then e.hold = value_at(api, e.env, at) end
344
+ end
345
+
346
+ local tempo = tempo_markers(api)
347
+ -- A ramp across the position would change slope (and so the timing before it) once its end moves
348
+ local ramp = ramp_across(tempo, at)
349
+ local ramp_bpm = ramp and bpm_at(tempo, at)
350
+ for i = #tempo, 1, -1 do
351
+ local m = tempo[i]
352
+ if m.t >= at - EPS then
353
+ api.SetTempoTimeSigMarker(0, i - 1, m.t + len, -1, -1, m.bpm, m.num, m.den, m.linear)
354
+ r.tempoMarkersMoved = r.tempoMarkersMoved + 1
355
+ end
356
+ end
357
+ if ramp then
358
+ -- End the ramp at the position, hold its tempo across the opened time, then carry on as before
359
+ add_tempo(api, at, ramp_bpm, false)
360
+ add_tempo(api, at + len, ramp_bpm, true)
361
+ r.tempoMarkersAdded = 2
362
+ end
363
+ retime(api)
364
+
365
+ for _, m in ipairs(snap.markers) do
366
+ if m.pos >= at - EPS then
367
+ api.SetProjectMarker4(0, m.num, m.rgn, m.pos + len, m.rgn and m.rend + len or 0, m.name, m.color, 0)
368
+ r.markersMoved = r.markersMoved + 1
369
+ elseif m.rgn and m.rend > at + EPS then
370
+ api.SetProjectMarker4(0, m.num, true, m.pos, m.rend + len, m.name, m.color, 0)
371
+ r.regionsExtended = r.regionsExtended + 1
372
+ end
373
+ end
374
+
375
+ for _, e in ipairs(snap.envs) do
376
+ local moved = false
377
+ for i, pt in ipairs(e.pts) do
378
+ if pt.t >= at - EPS then
379
+ api.SetEnvelopePoint(e.env, i - 1, pt.t + len, pt.v, pt.shape, pt.tension, pt.sel, true)
380
+ r.envelopePointsMoved = r.envelopePointsMoved + 1
381
+ moved = true
382
+ end
383
+ end
384
+ if e.hold then
385
+ api.InsertEnvelopePoint(e.env, at, e.hold, 0, 0, false, true)
386
+ -- A point that sat on the position now ends the opened time itself
387
+ -- and carries on with the shape of the segment it split (a square Mute envelope stays square)
388
+ if not has_at(e.pts, at) then
389
+ local shape, tension = shape_through(e.pts, at)
390
+ api.InsertEnvelopePoint(e.env, at + len, e.hold, shape, tension, false, true)
391
+ end
392
+ end
393
+ if moved then api.Envelope_SortPoints(e.env) end
394
+ for i, ai in ipairs(e.ais) do
395
+ if ai.pos >= at - EPS then
396
+ api.GetSetAutomationItemInfo(e.env, i - 1, "D_POSITION", ai.pos + len, true)
397
+ r.automationItemsMoved = r.automationItemsMoved + 1
398
+ elseif ai.pos + ai.len > at + EPS then
399
+ r.automationItemsSpanning = r.automationItemsSpanning + 1
400
+ end
401
+ end
402
+ end
403
+
404
+ for _, x in ipairs(snap.items) do
405
+ if x.pos >= at - EPS then
406
+ api.SetMediaItemInfo_Value(x.it, "D_POSITION", x.pos + len)
407
+ api.SetMediaItemInfo_Value(x.it, "D_LENGTH", x.len)
408
+ r.itemsMoved = r.itemsMoved + 1
409
+ elseif c.split and x.pos + x.len > at + EPS then
410
+ local right = split_at(api, x.it, at)
411
+ if right then
412
+ api.SetMediaItemInfo_Value(right, "D_POSITION", at + len)
413
+ r.itemsSplit = r.itemsSplit + 1
414
+ end
415
+ end
416
+ end
417
+ return r
418
+ end
419
+
420
+ function M.insert_time(api, p)
421
+ local c, err = check_insert(api, p)
422
+ if not c then return nil, err .. " — nothing changed" end
423
+ return with_undo(api, "MCP: Insert time", function() return apply_insert(api, c) end)
424
+ end
425
+
426
+ local function check_remove(api, p)
427
+ if not num(p.start) or p.start < 0 then return nil, "start must be 0 or more seconds" end
428
+ if not num(p["end"]) or p["end"] <= p.start then return nil, "end must be after start" end
429
+ if p["end"] - p.start > M.MAX_SPAN then return nil, "at most " .. M.MAX_SPAN .. " seconds per call" end
430
+ return { s = p.start, e = p["end"] }
431
+ end
432
+
433
+ -- Delete only the given automation items (by index on their envelope): REAPER has no API call,
434
+ -- so they are selected alone, deleted by action, and every other item's selection put back.
435
+ local function delete_automation_items(api, doomed)
436
+ if #doomed == 0 then return end
437
+ local keep, want = {}, {}
438
+ for _, d in ipairs(doomed) do want[d[1]] = want[d[1]] or {}; want[d[1]][d[2]] = true end
439
+ each_envelope(api, function(env)
440
+ for i = 0, api.CountAutomationItems(env) - 1 do
441
+ local was = api.GetSetAutomationItemInfo(env, i, "D_UISEL", 0, false) ~= 0
442
+ local doom = want[env] and want[env][i] or false
443
+ if not doom and was then keep[#keep + 1] = { env, i } end
444
+ api.GetSetAutomationItemInfo(env, i, "D_UISEL", doom and 1 or 0, true)
445
+ end
446
+ end)
447
+ api.Main_OnCommand(M.DELETE_AUTOMATION_ITEMS, 0)
448
+ for _, k in ipairs(keep) do
449
+ local i = k[2]
450
+ for d in pairs(want[k[1]] or {}) do if d < k[2] then i = i - 1 end end
451
+ api.GetSetAutomationItemInfo(k[1], i, "D_UISEL", 1, true)
452
+ end
453
+ end
454
+
455
+ local function apply_remove(api, c)
456
+ local s, e = c.s, c.e
457
+ local len = e - s
458
+ local function map(t) if t < s then return t elseif t >= e then return t - len end return s end
459
+ local r = { start = r3(s), ["end"] = r3(e), itemsDeleted = 0, itemsTrimmed = 0, itemsMoved = 0, markersDeleted = 0,
460
+ markersMoved = 0, regionsDeleted = 0, regionsTrimmed = 0, tempoMarkersDeleted = 0, tempoMarkersMoved = 0,
461
+ envelopePointsDeleted = 0, envelopePointsMoved = 0, automationItemsDeleted = 0, automationItemsMoved = 0,
462
+ automationItemsKept = 0, automationItemsTrimmed = 0 }
463
+ local snap = snapshot(api)
464
+ -- The values either side of the cut, kept at its edges so the automation outside it keeps its shape
465
+ for _, x in ipairs(snap.envs) do
466
+ if has_before(x.pts, s) and s - PIN >= 0 then x.left = value_at(api, x.env, s - PIN) end
467
+ -- Also when the last point is inside the cut: it is deleted, and its value is what plays after the cut
468
+ if has_from(x.pts, s) and not has_at(x.pts, e) then x.right = value_at(api, x.env, e) end
469
+ end
470
+
471
+ -- Tempo: markers inside go, except the last one, which moves to the cut so the tempo after
472
+ -- the cut stays what it was (unless a marker sits on the cut's end already)
473
+ local tempo = tempo_markers(api)
474
+ -- Ramps across the cut's edges: before it, the ramp keeps its slope up to the cut; after it,
475
+ -- the tempo starts where it stood at the cut's end and ramps on as it did
476
+ local ramp_in = ramp_across(tempo, s)
477
+ local bpm_in = ramp_in and bpm_at(tempo, s - PIN)
478
+ local bpm_out = bpm_at(tempo, e)
479
+ local in_force_out
480
+ for _, m in ipairs(tempo) do if m.t <= e + EPS then in_force_out = m end end
481
+ local ramp_out = in_force_out and in_force_out.linear and math.abs(in_force_out.t - e) > EPS and ramp_across(tempo, e) ~= nil
482
+ local inside, at_end = {}, false
483
+ for i, m in ipairs(tempo) do
484
+ if m.t >= s - EPS and m.t < e - EPS then inside[#inside + 1] = i end
485
+ if math.abs(m.t - e) < EPS then at_end = true end
486
+ end
487
+ local keep_last = (not at_end) and inside[#inside] or nil
488
+ -- Inside first: delete from the last so earlier indexes hold, then move the kept one to
489
+ -- the cut (after the deletes, so it never lands on a marker that is still there). Shifting
490
+ -- what follows comes last: done first it could slide a later marker before an inside one.
491
+ for k = #inside, 1, -1 do
492
+ if inside[k] ~= keep_last then
493
+ api.DeleteTempoTimeSigMarker(0, inside[k] - 1)
494
+ r.tempoMarkersDeleted = r.tempoMarkersDeleted + 1
495
+ end
496
+ end
497
+ if keep_last and math.abs(tempo[keep_last].t - s) > EPS then
498
+ local kept = tempo[keep_last]
499
+ for i, m in ipairs(tempo_markers(api)) do
500
+ if math.abs(m.t - kept.t) < EPS then
501
+ api.SetTempoTimeSigMarker(0, i - 1, s, -1, -1, m.bpm, m.num, m.den, m.linear)
502
+ r.tempoMarkersMoved = r.tempoMarkersMoved + 1
503
+ break
504
+ end
505
+ end
506
+ end
507
+ tempo = tempo_markers(api)
508
+ for i, m in ipairs(tempo) do
509
+ if m.t >= e - EPS then
510
+ api.SetTempoTimeSigMarker(0, i - 1, m.t - len, -1, -1, m.bpm, m.num, m.den, m.linear)
511
+ r.tempoMarkersMoved = r.tempoMarkersMoved + 1
512
+ end
513
+ end
514
+ if ramp_out then
515
+ local placed = false
516
+ for i, m in ipairs(tempo_markers(api)) do
517
+ if math.abs(m.t - s) < EPS then
518
+ api.SetTempoTimeSigMarker(0, i - 1, s, -1, -1, bpm_out, m.num, m.den, true)
519
+ placed = true
520
+ break
521
+ end
522
+ end
523
+ if not placed then add_tempo(api, s, bpm_out, true) end
524
+ end
525
+ if ramp_in then add_tempo(api, s - PIN, bpm_in, true) end
526
+ retime(api)
527
+
528
+ for _, m in ipairs(snap.markers) do
529
+ if not m.rgn then
530
+ if m.pos >= s - EPS and m.pos < e - EPS then
531
+ api.DeleteProjectMarker(0, m.num, false)
532
+ r.markersDeleted = r.markersDeleted + 1
533
+ elseif m.pos >= e - EPS then
534
+ api.SetProjectMarker4(0, m.num, false, m.pos - len, 0, m.name, m.color, 0)
535
+ r.markersMoved = r.markersMoved + 1
536
+ end
537
+ else
538
+ local a, b = map(m.pos), map(m.rend)
539
+ if b - a <= EPS then
540
+ api.DeleteProjectMarker(0, m.num, true)
541
+ r.regionsDeleted = r.regionsDeleted + 1
542
+ elseif a ~= m.pos or b ~= m.rend then
543
+ api.SetProjectMarker4(0, m.num, true, a, b, m.name, m.color, 0)
544
+ if b - a < m.rend - m.pos - EPS then r.regionsTrimmed = r.regionsTrimmed + 1 else r.markersMoved = r.markersMoved + 1 end
545
+ end
546
+ end
547
+ end
548
+
549
+ -- Envelope points, from the last so a delete leaves the earlier indexes as they were
550
+ local doomed = {}
551
+ for _, x in ipairs(snap.envs) do
552
+ local moved = false
553
+ for i = #x.pts, 1, -1 do
554
+ local pt = x.pts[i]
555
+ if pt.t >= s - EPS and pt.t < e - EPS then
556
+ api.DeleteEnvelopePointEx(x.env, -1, i - 1)
557
+ r.envelopePointsDeleted = r.envelopePointsDeleted + 1
558
+ elseif pt.t >= e - EPS then
559
+ api.SetEnvelopePoint(x.env, i - 1, pt.t - len, pt.v, pt.shape, pt.tension, pt.sel, true)
560
+ r.envelopePointsMoved = r.envelopePointsMoved + 1
561
+ moved = true
562
+ end
563
+ end
564
+ if x.left then
565
+ -- The pin carries the segment's shape, so a held step before the cut stays a step
566
+ local shape, tension = shape_through(x.pts, s - PIN)
567
+ api.InsertEnvelopePoint(x.env, s - PIN, x.left, shape, tension, false, true)
568
+ end
569
+ if x.right then
570
+ local shape, tension = shape_through(x.pts, e)
571
+ api.InsertEnvelopePoint(x.env, s, x.right, shape, tension, false, true)
572
+ end
573
+ if moved or x.left or x.right then api.Envelope_SortPoints(x.env) end
574
+ for i, ai in ipairs(x.ais) do
575
+ if ai.pos >= s - EPS and ai.pos + ai.len <= e + EPS then doomed[#doomed + 1] = { x.env, i - 1 } end
576
+ end
577
+ end
578
+ -- Automation items: delete those inside first, then move the later ones by their index after the delete
579
+ delete_automation_items(api, doomed)
580
+ r.automationItemsDeleted = #doomed
581
+ for _, x in ipairs(snap.envs) do
582
+ local gone = 0
583
+ for i, ai in ipairs(x.ais) do
584
+ if ai.pos >= s - EPS and ai.pos + ai.len <= e + EPS then
585
+ gone = gone + 1
586
+ elseif ai.pos >= e - EPS then
587
+ api.GetSetAutomationItemInfo(x.env, i - 1 - gone, "D_POSITION", ai.pos - len, true)
588
+ r.automationItemsMoved = r.automationItemsMoved + 1
589
+ elseif ai.pos >= s - EPS and ai.pos + ai.len > e + EPS then
590
+ -- Starts in the cut (or on its start), ends after it: keep the part after the cut, like a media item
591
+ local idx, cut_in = i - 1 - gone, e - ai.pos
592
+ local rate = api.GetSetAutomationItemInfo(x.env, idx, "D_PLAYRATE", 0, false)
593
+ if rate <= 0 then rate = 1 end
594
+ local offs = api.GetSetAutomationItemInfo(x.env, idx, "D_STARTOFFS", 0, false)
595
+ api.GetSetAutomationItemInfo(x.env, idx, "D_STARTOFFS", offs + cut_in * rate, true)
596
+ api.GetSetAutomationItemInfo(x.env, idx, "D_POSITION", s, true)
597
+ api.GetSetAutomationItemInfo(x.env, idx, "D_LENGTH", ai.len - cut_in, true)
598
+ r.automationItemsTrimmed = r.automationItemsTrimmed + 1
599
+ elseif ai.pos + ai.len <= e + EPS and ai.pos + ai.len > s + EPS then
600
+ -- Starts before the cut, ends in it: drop its tail inside the cut
601
+ api.GetSetAutomationItemInfo(x.env, i - 1 - gone, "D_LENGTH", s - ai.pos, true)
602
+ r.automationItemsTrimmed = r.automationItemsTrimmed + 1
603
+ elseif ai.pos + ai.len > s + EPS then
604
+ -- Spans the whole cut: one item cannot lose its middle, so it stays and is counted
605
+ r.automationItemsKept = r.automationItemsKept + 1
606
+ end
607
+ end
608
+ end
609
+
610
+ for _, x in ipairs(snap.items) do
611
+ local stop = x.pos + x.len
612
+ if x.pos >= e - EPS then
613
+ api.SetMediaItemInfo_Value(x.it, "D_POSITION", x.pos - len)
614
+ api.SetMediaItemInfo_Value(x.it, "D_LENGTH", x.len)
615
+ r.itemsMoved = r.itemsMoved + 1
616
+ elseif x.pos >= s - EPS and stop <= e + EPS then
617
+ api.DeleteTrackMediaItem(x.tr, x.it)
618
+ r.itemsDeleted = r.itemsDeleted + 1
619
+ elseif stop > s + EPS then
620
+ -- Crosses the cut: keep what lies outside it
621
+ local middle = x.it
622
+ if x.pos < s - EPS then middle = split_at(api, x.it, s) end
623
+ local right = stop > e + EPS and split_at(api, middle, e) or nil
624
+ api.DeleteTrackMediaItem(x.tr, middle)
625
+ if right then api.SetMediaItemInfo_Value(right, "D_POSITION", s) end
626
+ r.itemsTrimmed = r.itemsTrimmed + 1
627
+ end
628
+ end
629
+ return r
630
+ end
631
+
632
+ function M.remove_time(api, p)
633
+ local c, err = check_remove(api, p)
634
+ if not c then return nil, err .. " — nothing changed" end
635
+ return with_undo(api, "MCP: Remove time", function() return apply_remove(api, c) end)
636
+ end
637
+
638
+ -- -----------------------------------------------------------------------------
639
+ -- Copy a section
640
+ -- -----------------------------------------------------------------------------
641
+
642
+ -- regionIndex: the region's number, the `index` list_regions returns (and edit/delete_region take)
643
+ local function region_range(api, idx)
644
+ for _, m in ipairs(markers(api)) do
645
+ if m.rgn and m.num == idx then return m.pos, m.rend, m.name end
646
+ end
647
+ end
648
+
649
+ local function check_section(api, p)
650
+ local s, e, name
651
+ if p.regionIndex ~= nil then
652
+ if not int(p.regionIndex) or p.regionIndex < 0 then return nil, "regionIndex must be 0 or more" end
653
+ s, e, name = region_range(api, p.regionIndex)
654
+ if not s then return nil, "no region " .. p.regionIndex .. " (list_regions gives the indexes)" end
655
+ else
656
+ s, e = p.start, p["end"]
657
+ if not num(s) or s < 0 then return nil, "give regionIndex, or start and end in seconds" end
658
+ if not num(e) or e <= s then return nil, "end must be after start" end
659
+ end
660
+ if e - s > M.MAX_SPAN then return nil, "at most " .. M.MAX_SPAN .. " seconds per call" end
661
+ if not num(p.position) or p.position < 0 then return nil, "position must be 0 or more seconds" end
662
+ local dest, len = p.position, e - s
663
+ if p.insert ~= nil and type(p.insert) ~= "boolean" then return nil, "insert must be true or false" end
664
+ if p.envelopes ~= nil and type(p.envelopes) ~= "boolean" then return nil, "envelopes must be true or false" end
665
+ if p.insert then
666
+ if dest > s + EPS and dest < e - EPS then return nil, "position is inside the section; inserting there would split it" end
667
+ elseif dest < e - EPS and dest + len > s + EPS then
668
+ return nil, "the copy would overlap the section itself; pick a position at or after its end (" .. r3(e) .. " s), or before its start minus its length"
669
+ end
670
+ local tracks = {}
671
+ if p.tracks ~= nil then
672
+ if type(p.tracks) ~= "table" or #p.tracks == 0 then return nil, "tracks must be a non-empty list of track indexes" end
673
+ for k, idx in ipairs(p.tracks) do
674
+ local tr, err = track_at(api, idx, "tracks[" .. (k - 1) .. "]")
675
+ if not tr then return nil, err end
676
+ tracks[#tracks + 1] = tr
677
+ end
678
+ else
679
+ for t = 0, api.CountTracks(0) - 1 do tracks[#tracks + 1] = api.GetTrack(0, t) end
680
+ end
681
+ local count = 0
682
+ for _, tr in ipairs(tracks) do
683
+ for i = 0, api.CountTrackMediaItems(tr) - 1 do
684
+ local it = api.GetTrackMediaItem(tr, i)
685
+ local pos = item_pos(api, it)
686
+ if pos < e - EPS and pos + item_len(api, it) > s + EPS then count = count + 1 end
687
+ end
688
+ end
689
+ if count > M.MAX_COPIES then return nil, "the section holds " .. count .. " items; at most " .. M.MAX_COPIES .. " per call" end
690
+ return { s = s, e = e, len = len, dest = dest, name = name, tracks = tracks, insert = p.insert == true,
691
+ envelopes = p.envelopes ~= false, all_tracks = p.tracks == nil }
692
+ end
693
+
694
+ -- Automation items wholly inside [s, e) get a pooled instance at the destination, so a section level
695
+ -- (one pool per section type) keeps editing as one; items crossing an edge are counted, not cut
696
+ local AI_KEYS = { "D_STARTOFFS", "D_PLAYRATE", "D_BASELINE", "D_AMPLITUDE", "D_LOOPSRC" }
697
+ local function copy_automation_items(api, env, s, e, offset, r)
698
+ local inside = {}
699
+ for i = 0, api.CountAutomationItems(env) - 1 do
700
+ local pos = api.GetSetAutomationItemInfo(env, i, "D_POSITION", 0, false)
701
+ local len = api.GetSetAutomationItemInfo(env, i, "D_LENGTH", 0, false)
702
+ if pos >= s - EPS and pos + len <= e + EPS then
703
+ local props = {}
704
+ for _, k in ipairs(AI_KEYS) do props[k] = api.GetSetAutomationItemInfo(env, i, k, 0, false) end
705
+ inside[#inside + 1] = { pool = math.floor(api.GetSetAutomationItemInfo(env, i, "D_POOL_ID", 0, false) + 0.5), pos = pos, len = len, props = props }
706
+ elseif pos < e - EPS and pos + len > s + EPS then
707
+ r.automationItemsCrossing = r.automationItemsCrossing + 1
708
+ end
709
+ end
710
+ for _, ai in ipairs(inside) do
711
+ local idx = api.InsertAutomationItem(env, ai.pool, ai.pos + offset, ai.len)
712
+ if idx and idx >= 0 then
713
+ for k, v in pairs(ai.props) do api.GetSetAutomationItemInfo(env, idx, k, v, true) end
714
+ r.automationItems = r.automationItems + 1
715
+ end
716
+ end
717
+ end
718
+
719
+ local function apply_section(api, c)
720
+ local s, e, len, dest = c.s, c.e, c.len, c.dest
721
+ local r = { start = r3(s), ["end"] = r3(e), position = r3(dest), length = r3(len), region = c.name }
722
+ if c.insert then
723
+ r.inserted = apply_insert(api, { at = dest, len = len, split = true })
724
+ -- Opening time at or before the section moves the section along with everything else
725
+ if dest <= s + EPS then s, e = s + len, e + len end
726
+ end
727
+ local offset = dest - s
728
+ local made = {}
729
+ for _, tr in ipairs(c.tracks) do
730
+ local sources = {}
731
+ for i = 0, api.CountTrackMediaItems(tr) - 1 do
732
+ local it = api.GetTrackMediaItem(tr, i)
733
+ local pos = item_pos(api, it)
734
+ if pos < e - EPS and pos + item_len(api, it) > s + EPS then sources[#sources + 1] = { it, pos } end
735
+ end
736
+ for _, src in ipairs(sources) do
737
+ local copy = copy_item(api, src[1], tr, src[2] + offset, false)
738
+ -- Trim the copy to the section by splitting at its edges and dropping the outside parts
739
+ if src[2] < s - EPS then
740
+ local inner = split_at(api, copy, dest)
741
+ api.DeleteTrackMediaItem(tr, copy)
742
+ copy = inner
743
+ end
744
+ if copy and item_pos(api, copy) + item_len(api, copy) > dest + len + EPS then
745
+ local outer = split_at(api, copy, dest + len)
746
+ if outer then api.DeleteTrackMediaItem(tr, outer) end
747
+ end
748
+ if copy then made[#made + 1] = copy end
749
+ end
750
+ end
751
+ r.items = describe_all(api, made)
752
+ r.itemCount = #made
753
+ r.envelopePoints = 0
754
+ r.automationItems, r.automationItemsCrossing = 0, 0
755
+ if c.envelopes then
756
+ local wanted = {}
757
+ for _, tr in ipairs(c.tracks) do wanted[tr] = true end
758
+ each_envelope(api, function(env, tr)
759
+ if not (wanted[tr] or (c.all_tracks and tr == api.GetMasterTrack(0))) then return end
760
+ local all, pts = {}, {}
761
+ for i = 0, api.CountEnvelopePoints(env) - 1 do
762
+ local pt = point(api, env, i)
763
+ all[#all + 1] = pt
764
+ if pt.t >= s - EPS and pt.t < e - EPS then pts[#pts + 1] = pt end
765
+ end
766
+ copy_automation_items(api, env, s, e, offset, r)
767
+ if #all == 0 then return end
768
+ -- Values at the section's edges, so the copy starts and ends where the source does
769
+ local _, v_start = api.Envelope_Evaluate(env, s, 0, 0)
770
+ local _, v_end = api.Envelope_Evaluate(env, e, 0, 0)
771
+ -- And the destination's own values just outside the copy, so ramps leading in and out keep their shape
772
+ local before, after = dest - PIN, dest + len + PIN
773
+ local _, v_before = api.Envelope_Evaluate(env, before, 0, 0)
774
+ local _, v_after = api.Envelope_Evaluate(env, after, 0, 0)
775
+ -- Also with insert: the opened time's hold points would double the copy's own edge points
776
+ api.DeleteEnvelopePointRange(env, dest - EPS, dest + len + EPS)
777
+ if before >= 0 then api.InsertEnvelopePoint(env, before, v_before, 0, 0, false, true) end
778
+ -- The edge points carry the shape of the segment they sit in, so a held or curved segment stays one
779
+ local after_shape, after_tension = shape_through(all, after)
780
+ api.InsertEnvelopePoint(env, after, v_after, after_shape, after_tension, false, true)
781
+ -- With no point inside, the start point alone carries a ramp or level running through the section
782
+ if #pts == 0 or math.abs(pts[1].t - s) > EPS then
783
+ local start_shape, start_tension = shape_through(all, s)
784
+ api.InsertEnvelopePoint(env, dest, v_start, start_shape, start_tension, false, true)
785
+ end
786
+ for _, pt in ipairs(pts) do
787
+ api.InsertEnvelopePoint(env, pt.t + offset, pt.v, pt.shape, pt.tension, false, true)
788
+ end
789
+ api.InsertEnvelopePoint(env, dest + len, v_end, 0, 0, false, true)
790
+ api.Envelope_SortPoints(env)
791
+ r.envelopePoints = r.envelopePoints + #pts
792
+ end)
793
+ end
794
+ return r
795
+ end
796
+
797
+ function M.copy_section(api, p)
798
+ local c, err = check_section(api, p)
799
+ if not c then return nil, err .. " — nothing changed" end
800
+ return with_undo(api, "MCP: Copy section", function() return apply_section(api, c) end)
801
+ end
802
+
803
+ -- -----------------------------------------------------------------------------
804
+ -- Process items: normalize, reverse, glue
805
+ -- -----------------------------------------------------------------------------
806
+
807
+ -- The measured level (dB) of the part of the active take's source the item plays
808
+ local function take_level(api, it, mode)
809
+ local take = api.GetActiveTake(it)
810
+ local src = take and api.GetMediaItemTake_Source(take)
811
+ if not src then return nil end
812
+ local offs = api.GetMediaItemTakeInfo_Value(take, "D_STARTOFFS")
813
+ local rate = api.GetMediaItemTakeInfo_Value(take, "D_PLAYRATE")
814
+ if rate <= 0 then rate = 1 end
815
+ local src_len = api.GetMediaSourceLength(src)
816
+ local stop = math.min(offs + item_len(api, it) * rate, src_len)
817
+ -- With a 0 dB target the gain REAPER returns is the level, inverted
818
+ local gain = api.CalculateNormalization(src, mode, 0, math.max(0, offs), stop)
819
+ if not gain or gain <= 0 then return nil end
820
+ return -20 * math.log(gain, 10), take
821
+ end
822
+
823
+ local function check_process(api, p)
824
+ local items, err = resolve_items(api, p.items)
825
+ if not items then return nil, err end
826
+ local op = p.operation
827
+ if op ~= "normalize" and op ~= "reverse" and op ~= "glue" then return nil, "operation must be normalize, reverse or glue" end
828
+ local c = { op = op, items = items }
829
+ if op == "normalize" or op == "reverse" then
830
+ for k, s in ipairs(items) do
831
+ if not api.GetActiveTake(s.item) then return nil, "items[" .. (k - 1) .. "] has no take" end
832
+ if active_take_is_midi(api, s.item) then return nil, "items[" .. (k - 1) .. "] is MIDI; " .. op .. " works on audio items" end
833
+ end
834
+ end
835
+ if op == "normalize" then
836
+ local mode = p.mode or "peak"
837
+ c.mode = M.NORMALIZE_MODES[mode]
838
+ if not c.mode then return nil, "mode must be peak, true_peak, lufs_i, rms, lufs_m_max or lufs_s_max" end
839
+ c.mode_name = mode
840
+ local target = p.target
841
+ if target == nil then
842
+ if mode ~= "peak" and mode ~= "true_peak" then return nil, "target (dB) is required for mode " .. mode end
843
+ target = 0
844
+ end
845
+ if not num(target) or target < -70 or target > 0 then return nil, "target must be -70 to 0 dB" end
846
+ c.target = target
847
+ for k, s in ipairs(items) do
848
+ s.level, s.take = take_level(api, s.item, c.mode)
849
+ if not s.level then return nil, "items[" .. (k - 1) .. "] is silent or its source can't be measured" end
850
+ end
851
+ end
852
+ return c
853
+ end
854
+
855
+ local function apply_process(api, c)
856
+ local r = { operation = c.op }
857
+ if c.op == "normalize" then
858
+ r.mode, r.target, r.items = c.mode_name, c.target, {}
859
+ for _, s in ipairs(c.items) do
860
+ local before = api.GetMediaItemInfo_Value(s.item, "D_VOL")
861
+ -- A negative take volume is a polarity flip: its level is the magnitude
862
+ local take_vol = math.abs(api.GetMediaItemTakeInfo_Value(s.take, "D_VOL"))
863
+ if take_vol == 0 then take_vol = 1 end
864
+ local gain = 10 ^ ((c.target - s.level) / 20) / take_vol
865
+ api.SetMediaItemInfo_Value(s.item, "D_VOL", gain)
866
+ local d = describe(api, s.item)
867
+ d.levelBefore = r3(s.level + db(before) + db(take_vol))
868
+ d.levelAfter = r3(c.target)
869
+ d.gainBeforeDb, d.gainAfterDb = db(before), db(gain)
870
+ r.items[#r.items + 1] = d
871
+ end
872
+ return r
873
+ end
874
+ local sel = save_item_selection(api)
875
+ api.SelectAllMediaItems(0, false)
876
+ for _, s in ipairs(c.items) do api.SetMediaItemSelected(s.item, true) end
877
+ local ok, err = pcall(function()
878
+ if c.op == "reverse" then
879
+ api.Main_OnCommand(M.REVERSE, 0)
880
+ r.items = {}
881
+ for _, s in ipairs(c.items) do r.items[#r.items + 1] = describe(api, s.item) end
882
+ else
883
+ -- The glued items are the ones that did not exist before, on the tracks the sources are on
884
+ -- (or a source still there afterwards, should REAPER reuse it for the result)
885
+ local existed, tracks, selected_on, source = {}, {}, {}, {}
886
+ local was_sel = {}
887
+ for _, it in ipairs(sel) do was_sel[it] = true end
888
+ for _, s in ipairs(c.items) do
889
+ if not tracks[s.track] then
890
+ tracks[s.track] = true
891
+ for i = 0, api.CountTrackMediaItems(s.track) - 1 do existed[api.GetTrackMediaItem(s.track, i)] = true end
892
+ end
893
+ source[s.item] = true
894
+ if was_sel[s.item] then selected_on[s.track] = true end
895
+ end
896
+ api.Main_OnCommand(M.GLUE, 0)
897
+ local glued = {}
898
+ for t = 0, api.CountTracks(0) - 1 do
899
+ local tr = api.GetTrack(0, t)
900
+ if tracks[tr] then
901
+ for i = 0, api.CountTrackMediaItems(tr) - 1 do
902
+ local it = api.GetTrackMediaItem(tr, i)
903
+ if not existed[it] or source[it] then
904
+ glued[#glued + 1] = it
905
+ -- A selected source passes its selection on to the item that replaced it on its track
906
+ if selected_on[tr] then sel[#sel + 1] = it end
907
+ end
908
+ end
909
+ end
910
+ end
911
+ r.items = describe_all(api, glued)
912
+ end
913
+ end)
914
+ restore_item_selection(api, sel)
915
+ if not ok then error(err, 0) end
916
+ return r
917
+ end
918
+
919
+ function M.process_items(api, p)
920
+ local c, err = check_process(api, p)
921
+ if not c then return nil, err .. " — nothing changed" end
922
+ return with_undo(api, "MCP: " .. c.op:sub(1, 1):upper() .. c.op:sub(2) .. " items", function() return apply_process(api, c) end)
923
+ end
924
+
925
+ -- -----------------------------------------------------------------------------
926
+ -- run_plan ops: copy_items, insert_time, remove_time
927
+ -- -----------------------------------------------------------------------------
928
+
929
+ local OPS = {
930
+ copy_items = { check_copy, apply_copy },
931
+ insert_time = { check_insert, apply_insert },
932
+ remove_time = { check_remove, apply_remove },
933
+ }
934
+ M.PLAN_OPS = { copy_items = true, insert_time = true, remove_time = true }
935
+
936
+ --- Checks a plan op without writing; returns an error string or nil
937
+ function M.check_op(api, op)
938
+ local _, err = OPS[op.op][1](api, op)
939
+ return err
940
+ end
941
+
942
+ --- Runs a plan op inside the plan's undo block; raises on a refusal so the plan is undone
943
+ function M.apply_op(api, op)
944
+ local c, err = OPS[op.op][1](api, op)
945
+ if not c then error(op.op .. ": " .. err, 0) end
946
+ return OPS[op.op][2](api, c)
947
+ end
948
+
949
+ return M