@mthines/reaper-mcp 0.19.0 → 0.70.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -570
- package/bridge-ui/mcp_bridge.html +129 -0
- package/bridge-ui/mcp_bridge.webmanifest +16 -0
- package/bridge-ui/mcp_bridge_icon_192.png +0 -0
- package/bridge-ui/mcp_bridge_icon_512.png +0 -0
- package/bridge-ui/mcp_bridge_sw.js +35 -0
- package/claude/agents/plugin-researcher.md +188 -0
- package/claude/knowledge/reference/aesthetics-scoring.md +48 -0
- package/{knowledge → claude/knowledge}/reference/common-mistakes.md +1 -1
- package/claude/knowledge/reference/lorekit-setup.md +36 -0
- package/claude/knowledge/reference/memory-protocol.md +377 -0
- package/{knowledge → claude/knowledge}/reference/metering.md +5 -5
- package/claude/knowledge/reference/perceived-loudness.md +182 -0
- package/{knowledge → claude/knowledge}/workflows/editing.md +4 -0
- package/{knowledge → claude/knowledge}/workflows/gain-staging.md +21 -23
- package/{knowledge → claude/knowledge}/workflows/learn-plugin.md +15 -48
- package/{knowledge → claude/knowledge}/workflows/master-bus.md +13 -1
- package/claude/knowledge/workflows/section-levels.md +138 -0
- package/{knowledge → claude/knowledge}/workflows/session-prep.md +4 -0
- package/claude/knowledge/workflows/transient-lighting.md +52 -0
- package/claude/skills/critique/SKILL.md +131 -0
- package/claude/skills/editing/SKILL.md +281 -0
- package/claude/skills/learn-plugin/SKILL.md +210 -0
- package/claude/skills/mastering/SKILL.md +151 -0
- package/claude/skills/mix-memory/SKILL.md +140 -0
- package/claude/skills/mixer/SKILL.md +253 -0
- package/claude/skills/new-extension/SKILL.md +324 -0
- package/main.js +7843 -1940
- package/package.json +17 -15
- package/reaper/CLAUDE.md +22 -4
- package/reaper/mcp_actions_lib.lua +375 -0
- package/reaper/mcp_analyzer.jsfx +27 -12
- package/reaper/mcp_arrange_lib.lua +949 -0
- package/reaper/mcp_bridge.lua +2321 -758
- package/reaper/mcp_edit_views_lib.lua +1378 -0
- package/reaper/mcp_envelopes_lib.lua +748 -0
- package/reaper/mcp_extensions_lib.lua +55 -0
- package/reaper/mcp_fxstate_lib.lua +144 -0
- package/reaper/mcp_gain_trace.jsfx +126 -0
- package/reaper/mcp_items_lib.lua +877 -0
- package/reaper/mcp_lufs_meter.jsfx +16 -13
- package/reaper/mcp_midi_lib.lua +555 -0
- package/reaper/mcp_mixing_lib.lua +1021 -0
- package/reaper/mcp_plan_lib.lua +801 -0
- package/reaper/mcp_prefs_lib.lua +127 -0
- package/reaper/mcp_project_lib.lua +1088 -0
- package/reaper/mcp_recording_lib.lua +595 -0
- package/reaper/mcp_render_lib.lua +492 -0
- package/reaper/mcp_snapshot_lib.lua +547 -120
- package/reaper/mcp_snapshot_next.lua +10 -4
- package/reaper/mcp_snapshot_prev.lua +11 -4
- package/reaper/mcp_snapshot_quick_save.lua +7 -32
- package/reaper/mcp_snapshots_ui_lib.lua +258 -0
- package/reaper/mcp_tracks_lib.lua +409 -0
- package/sidecar/analysis.py +661 -0
- package/sidecar/chords.py +96 -0
- package/sidecar/mixcheck.py +295 -0
- package/sidecar/requirements-chords.txt +6 -0
- package/sidecar/requirements-separation.txt +3 -0
- package/sidecar/requirements.txt +17 -0
- package/sidecar/separation.py +142 -0
- package/sidecar/server.py +622 -0
- package/sidecar/tests/test_analysis.py +249 -0
- package/sidecar/tests/test_chords.py +117 -0
- package/sidecar/tests/test_mixcheck.py +163 -0
- package/sidecar/tests/test_separation.py +73 -0
- package/sidecar/tests/test_server.py +464 -0
- package/sidecar/tests/test_verify.py +243 -0
- package/sidecar/tests/test_voice_render.py +268 -0
- package/sidecar/tests/test_voice_sketch.py +140 -0
- package/sidecar/verify.py +224 -0
- package/sidecar/voice_render.py +295 -0
- package/sidecar/voice_sketch.py +280 -0
- package/claude-agents/critique.md +0 -147
- package/claude-agents/editor.md +0 -184
- package/claude-agents/levels.md +0 -107
- package/claude-agents/mastering.md +0 -129
- package/claude-agents/mixer.md +0 -248
- package/claude-agents/preflight.md +0 -149
- package/claude-agents/producer.md +0 -159
- package/claude-agents/setup.md +0 -150
- package/claude-agents/stems.md +0 -135
- package/claude-rules/architecture.md +0 -39
- package/claude-rules/development.md +0 -54
- package/claude-rules/lua-bridge.md +0 -50
- package/claude-rules/testing.md +0 -42
- package/claude-skills/learn-plugin.md +0 -123
- package/knowledge/CLAUDE.md +0 -71
- package/knowledge/plugins/_template.md +0 -82
- package/knowledge/plugins/fabfilter/pro-c-2.md +0 -117
- package/knowledge/plugins/fabfilter/pro-l-2.md +0 -95
- package/knowledge/plugins/fabfilter/pro-q-3.md +0 -112
- package/knowledge/plugins/neural-dsp/helix-native.md +0 -104
- package/knowledge/plugins/stock-reaper/js-1175-compressor.md +0 -94
- package/knowledge/plugins/stock-reaper/rea-comp.md +0 -100
- package/knowledge/plugins/stock-reaper/rea-delay.md +0 -95
- package/knowledge/plugins/stock-reaper/rea-eq.md +0 -103
- package/knowledge/plugins/stock-reaper/rea-gate.md +0 -99
- package/knowledge/plugins/stock-reaper/rea-limit.md +0 -75
- package/knowledge/plugins/stock-reaper/rea-verb.md +0 -76
- package/knowledge/reference/perceived-loudness.md +0 -122
- package/reaper/mcp_snapshot_manager.lua +0 -1001
- /package/{knowledge → claude/knowledge}/genres/_template.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/electronic.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/hip-hop.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/metal.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/orchestral.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/pop.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/rock.md +0 -0
- /package/{knowledge → claude/knowledge}/reference/compression.md +0 -0
- /package/{knowledge → claude/knowledge}/reference/frequencies.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/delivery.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/drum-bus.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/low-end.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/stem-prep.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/vocal-chain.md +0 -0
|
@@ -0,0 +1,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
|