@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,1021 @@
1
+ -- Mixing depth for the MCP bridge: automation modes, FX parameter modulation (LFO, audio
2
+ -- control signal, parameter link), FX pin mappings, monitoring FX, freeze / render in place,
3
+ -- and stretch markers (batch add, quantize to the grid).
4
+ --
5
+ -- Pure Lua over an injected `api` (the `reaper` table in REAPER, a fake in the wasmoon
6
+ -- tests) and an injected track resolver (mcp_plan_lib.resolve_track). Every write checks
7
+ -- all of its input first, so a refused call changes nothing, and is one undo point.
8
+
9
+ local M = {}
10
+
11
+ M.MAX_TRACKS = 64
12
+ M.MAX_ITEMS = 64
13
+ M.MAX_STRETCH_MARKERS = 2000
14
+ M.MAX_MOD_SCAN = 1024
15
+ -- Monitoring FX live on the master track at this index offset
16
+ M.MONITOR = 0x1000000
17
+
18
+ -- REAPER's automation modes, by I_AUTOMODE value
19
+ M.AUTOMATION_MODES = { "trim", "read", "touch", "write", "latch", "latch_preview" }
20
+ -- Native action ids (fixed across REAPER versions; names checked live on REAPER 7.40)
21
+ M.FREEZE_ACTIONS = { stereo = 41223, mono = 40901, multichannel = 40877 }
22
+ M.UNFREEZE_ACTION = 41644
23
+ M.APPLY_FX_ACTIONS = { stereo = 40209, mono = 40361, multichannel = 41993 }
24
+ M.LFO_SHAPES = { "sine", "square", "saw_down", "saw_up", "triangle", "random" }
25
+
26
+ local function int(v)
27
+ local n = tonumber(v)
28
+ if not n then return nil end
29
+ return math.floor(n + 0.5)
30
+ end
31
+
32
+ local function round(v, places)
33
+ local m = 10 ^ (places or 3)
34
+ return math.floor(v * m + 0.5) / m
35
+ end
36
+
37
+ local function index_of(list, v)
38
+ for i, x in ipairs(list) do if x == v then return i end end
39
+ end
40
+
41
+ local function with_undo(api, label, flags, fn)
42
+ api.Undo_BeginBlock()
43
+ local ok, res, err = pcall(fn)
44
+ if not ok then
45
+ -- A write REAPER refused half way leaves no partial edit. The failed block gets its own label,
46
+ -- so a failure that wrote nothing (no undo point) never undoes the user's last edit
47
+ local revert = label .. " (failed — reverting)"
48
+ api.Undo_EndBlock(revert, flags)
49
+ local undone = api.Undo_CanUndo2 and api.Undo_CanUndo2(0) == revert
50
+ if undone then api.Undo_DoUndo2(0) end
51
+ error(tostring(res) .. (undone and " — the edit was undone" or ""), 0)
52
+ end
53
+ api.Undo_EndBlock(label, flags)
54
+ return res, err
55
+ end
56
+
57
+ local function track_name(api, tr)
58
+ if tr == api.GetMasterTrack(0) then return "MASTER" end
59
+ local _, name = api.GetTrackName(tr)
60
+ return name
61
+ end
62
+
63
+ local function track_index(api, tr)
64
+ local n = int(api.GetMediaTrackInfo_Value(tr, "IP_TRACKNUMBER"))
65
+ return (n == -1 or n == 0) and -1 or n - 1
66
+ end
67
+
68
+ -- A list of track refs (indexes, names, GUIDs, "master"), each resolved, no duplicates
69
+ local function resolve_tracks(api, resolve, refs, allow_master)
70
+ if type(refs) ~= "table" or #refs == 0 then return nil, "tracks must be a non-empty list" end
71
+ if #refs > M.MAX_TRACKS then return nil, "at most " .. M.MAX_TRACKS .. " tracks per call" end
72
+ local out, seen = {}, {}
73
+ for _, ref in ipairs(refs) do
74
+ local res, err = resolve(api, ref, {})
75
+ if not res or not res.track then return nil, err or ("track " .. tostring(ref) .. " not found") end
76
+ if not allow_master and res.track == api.GetMasterTrack(0) then return nil, "the master track cannot be used here" end
77
+ if not seen[res.track] then
78
+ seen[res.track] = true
79
+ out[#out + 1] = res.track
80
+ end
81
+ end
82
+ return out
83
+ end
84
+
85
+ local function get_track(api, idx)
86
+ idx = int(idx)
87
+ if idx == -1 then return api.GetMasterTrack(0) end
88
+ local tr = idx and api.GetTrack(0, idx)
89
+ if not tr then return nil, "Track " .. tostring(idx) .. " not found" end
90
+ return tr
91
+ end
92
+
93
+ local function get_fx(api, p)
94
+ local tr, err = get_track(api, p.trackIndex)
95
+ if not tr then return nil, err end
96
+ local fx = int(p.fxIndex)
97
+ local count = api.TrackFX_GetCount(tr)
98
+ if not fx or fx < 0 or fx >= count then
99
+ return nil, "FX index " .. tostring(p.fxIndex) .. " out of range (track has " .. count .. " FX)"
100
+ end
101
+ return tr, fx
102
+ end
103
+
104
+ -- -----------------------------------------------------------------------------
105
+ -- Automation mode
106
+ -- -----------------------------------------------------------------------------
107
+
108
+ local function mode_name(v)
109
+ return M.AUTOMATION_MODES[(int(v) or 0) + 1] or "trim"
110
+ end
111
+
112
+ -- Global override: -1 none, 0..5 the modes, 6 bypass (all automation off)
113
+ local function override_name(v)
114
+ v = int(v) or -1
115
+ if v < 0 then return "none" end
116
+ if v == 6 then return "bypass" end
117
+ return mode_name(v)
118
+ end
119
+
120
+ function M.set_automation_mode(api, resolve, p)
121
+ if p.mode == nil and p.globalOverride == nil then return nil, "Give mode (with tracks) and/or globalOverride" end
122
+ local mode = p.mode and index_of(M.AUTOMATION_MODES, p.mode)
123
+ if p.mode and not mode then
124
+ return nil, "Unknown mode " .. tostring(p.mode) .. ". Use " .. table.concat(M.AUTOMATION_MODES, ", ")
125
+ end
126
+ local override
127
+ if p.globalOverride ~= nil then
128
+ if p.globalOverride == "none" then override = -1
129
+ elseif p.globalOverride == "bypass" then override = 6
130
+ else
131
+ local i = index_of(M.AUTOMATION_MODES, p.globalOverride)
132
+ if not i then return nil, "Unknown globalOverride " .. tostring(p.globalOverride) .. ". Use none, bypass or a mode" end
133
+ override = i - 1
134
+ end
135
+ end
136
+ local tracks = {}
137
+ if mode then
138
+ local err
139
+ tracks, err = resolve_tracks(api, resolve, p.tracks, true)
140
+ if not tracks then return nil, err end
141
+ elseif p.tracks ~= nil then
142
+ return nil, "tracks needs a mode"
143
+ end
144
+
145
+ return with_undo(api, "MCP: Set automation mode", 1, function()
146
+ local rows = {}
147
+ for _, tr in ipairs(tracks) do
148
+ local before = mode_name(api.GetMediaTrackInfo_Value(tr, "I_AUTOMODE"))
149
+ api.SetMediaTrackInfo_Value(tr, "I_AUTOMODE", mode - 1)
150
+ rows[#rows + 1] = {
151
+ trackIndex = track_index(api, tr), name = track_name(api, tr),
152
+ before = before, mode = mode_name(api.GetMediaTrackInfo_Value(tr, "I_AUTOMODE")),
153
+ }
154
+ end
155
+ local res = { tracks = rows }
156
+ if override then
157
+ res.globalOverrideBefore = override_name(api.GetGlobalAutomationOverride())
158
+ api.SetGlobalAutomationOverride(override)
159
+ end
160
+ res.globalOverride = override_name(api.GetGlobalAutomationOverride())
161
+ return res
162
+ end)
163
+ end
164
+
165
+ -- -----------------------------------------------------------------------------
166
+ -- FX parameter modulation (REAPER 6.37+ named config: param.N.mod / lfo / acs / plink)
167
+ -- -----------------------------------------------------------------------------
168
+
169
+ local function cfg(api, tr, fx, key)
170
+ local ok, v = api.TrackFX_GetNamedConfigParm(tr, fx, key)
171
+ if not ok then return nil end
172
+ return v
173
+ end
174
+
175
+ local function cfg_num(api, tr, fx, key)
176
+ return tonumber(cfg(api, tr, fx, key))
177
+ end
178
+
179
+ local function cfg_on(api, tr, fx, key)
180
+ return cfg_num(api, tr, fx, key) == 1
181
+ end
182
+
183
+ -- One parameter's modulation, or nil when the parameter has none
184
+ local function read_modulation(api, tr, fx, param)
185
+ local k = "param." .. param .. "."
186
+ if not cfg_on(api, tr, fx, k .. "mod.active") then return nil end
187
+ local _, pname = api.TrackFX_GetParamName(tr, fx, param)
188
+ local m = {
189
+ paramIndex = param, name = pname,
190
+ baseline = round(cfg_num(api, tr, fx, k .. "mod.baseline") or 0, 4),
191
+ value = round(api.TrackFX_GetParamNormalized(tr, fx, param), 4),
192
+ }
193
+ if cfg_on(api, tr, fx, k .. "lfo.active") then
194
+ m.lfo = {
195
+ shape = M.LFO_SHAPES[(cfg_num(api, tr, fx, k .. "lfo.shape") or 0) + 1] or "sine",
196
+ speed = round(cfg_num(api, tr, fx, k .. "lfo.speed") or 0, 4),
197
+ strength = round(cfg_num(api, tr, fx, k .. "lfo.strength") or 0, 4),
198
+ phase = round(cfg_num(api, tr, fx, k .. "lfo.phase") or 0, 4),
199
+ tempoSync = cfg_on(api, tr, fx, k .. "lfo.temposync"),
200
+ direction = int(cfg_num(api, tr, fx, k .. "lfo.dir") or 1),
201
+ }
202
+ end
203
+ if cfg_on(api, tr, fx, k .. "acs.active") then
204
+ m.audio = {
205
+ attackMs = round(cfg_num(api, tr, fx, k .. "acs.attack") or 0, 2),
206
+ releaseMs = round(cfg_num(api, tr, fx, k .. "acs.release") or 0, 2),
207
+ minDb = round(cfg_num(api, tr, fx, k .. "acs.dblo") or 0, 2),
208
+ maxDb = round(cfg_num(api, tr, fx, k .. "acs.dbhi") or 0, 2),
209
+ strength = round(cfg_num(api, tr, fx, k .. "acs.strength") or 0, 4),
210
+ channel = int(cfg_num(api, tr, fx, k .. "acs.chan") or 0) + 1,
211
+ direction = int(cfg_num(api, tr, fx, k .. "acs.dir") or 1),
212
+ }
213
+ end
214
+ if cfg_on(api, tr, fx, k .. "plink.active") then
215
+ local src_fx, src_param = int(cfg_num(api, tr, fx, k .. "plink.effect")), int(cfg_num(api, tr, fx, k .. "plink.param"))
216
+ local link = {
217
+ fxIndex = src_fx, paramIndex = src_param,
218
+ scale = round(cfg_num(api, tr, fx, k .. "plink.scale") or 1, 4),
219
+ offset = round(cfg_num(api, tr, fx, k .. "plink.offset") or 0, 4),
220
+ }
221
+ if src_fx and src_fx >= 0 and src_fx < api.TrackFX_GetCount(tr) then
222
+ local _, fname = api.TrackFX_GetFXName(tr, src_fx)
223
+ local _, sname = api.TrackFX_GetParamName(tr, src_fx, src_param or 0)
224
+ link.fxName, link.paramName = fname, sname
225
+ end
226
+ m.link = link
227
+ end
228
+ return m
229
+ end
230
+
231
+ function M.get_modulation(api, p)
232
+ local tr, fx = get_fx(api, p)
233
+ if not tr then return nil, fx end
234
+ local n = api.TrackFX_GetNumParams(tr, fx)
235
+ local _, fx_name = api.TrackFX_GetFXName(tr, fx)
236
+ local res = { trackIndex = int(p.trackIndex), fxIndex = fx, fxName = fx_name, paramCount = n }
237
+ if p.paramIndex ~= nil then
238
+ local param = int(p.paramIndex)
239
+ if not param or param < 0 or param >= n then
240
+ return nil, "Parameter index " .. tostring(p.paramIndex) .. " out of range (FX has " .. n .. " parameters)"
241
+ end
242
+ res.modulated = { read_modulation(api, tr, fx, param) }
243
+ return res
244
+ end
245
+ local list = {}
246
+ for i = 0, math.min(n, M.MAX_MOD_SCAN) - 1 do
247
+ list[#list + 1] = read_modulation(api, tr, fx, i)
248
+ end
249
+ res.modulated = list
250
+ if n > M.MAX_MOD_SCAN then res.scanned = M.MAX_MOD_SCAN end
251
+ return res
252
+ end
253
+
254
+ local function check_range(v, lo, hi, what)
255
+ if v == nil then return true end
256
+ local n = tonumber(v)
257
+ if not n or n < lo or n > hi then return false, what .. " must be " .. lo .. ".." .. hi end
258
+ return true
259
+ end
260
+
261
+ -- The named-config writes a modulation spec turns into, after checking all of it
262
+ local function modulation_writes(api, tr, fx, param, spec)
263
+ local k = "param." .. param .. "."
264
+ local w = {}
265
+ local function add(key, v) w[#w + 1] = { k .. key, tostring(v) } end
266
+ if spec.enabled == false then
267
+ add("mod.active", 0)
268
+ return w
269
+ end
270
+ local ok, err = check_range(spec.baseline, 0, 1, "baseline")
271
+ if not ok then return nil, err end
272
+ add("mod.active", 1)
273
+ if spec.baseline ~= nil then add("mod.baseline", tonumber(spec.baseline)) end
274
+
275
+ local lfo = spec.lfo
276
+ if type(lfo) == "table" then
277
+ if lfo.enabled == false then add("lfo.active", 0) else
278
+ local shape = lfo.shape and index_of(M.LFO_SHAPES, lfo.shape)
279
+ if lfo.shape and not shape then return nil, "Unknown LFO shape " .. tostring(lfo.shape) .. ". Use " .. table.concat(M.LFO_SHAPES, ", ") end
280
+ for _, c in ipairs({ { lfo.strength, 0, 1, "lfo.strength" }, { lfo.phase, 0, 1, "lfo.phase" }, { lfo.speed, 0.0001, 64, "lfo.speed" } }) do
281
+ ok, err = check_range(c[1], c[2], c[3], c[4])
282
+ if not ok then return nil, err end
283
+ end
284
+ if lfo.direction ~= nil and not index_of({ -1, 0, 1 }, int(lfo.direction)) then return nil, "lfo.direction must be -1, 0 or 1" end
285
+ add("lfo.active", 1)
286
+ if shape then add("lfo.shape", shape - 1) end
287
+ if lfo.tempoSync ~= nil then add("lfo.temposync", lfo.tempoSync and 1 or 0) end
288
+ if lfo.speed ~= nil then add("lfo.speed", tonumber(lfo.speed)) end
289
+ if lfo.strength ~= nil then add("lfo.strength", tonumber(lfo.strength)) end
290
+ if lfo.phase ~= nil then add("lfo.phase", tonumber(lfo.phase)) end
291
+ if lfo.direction ~= nil then add("lfo.dir", int(lfo.direction)) end
292
+ end
293
+ end
294
+
295
+ local acs = spec.audio
296
+ if type(acs) == "table" then
297
+ if acs.enabled == false then add("acs.active", 0) else
298
+ for _, c in ipairs({ { acs.attackMs, 0, 1000, "audio.attackMs" }, { acs.releaseMs, 0, 1000, "audio.releaseMs" },
299
+ { acs.minDb, -60, 12, "audio.minDb" }, { acs.maxDb, -60, 12, "audio.maxDb" },
300
+ { acs.strength, 0, 1, "audio.strength" }, { acs.channel, 1, 64, "audio.channel" } }) do
301
+ ok, err = check_range(c[1], c[2], c[3], c[4])
302
+ if not ok then return nil, err end
303
+ end
304
+ if acs.minDb and acs.maxDb and tonumber(acs.minDb) >= tonumber(acs.maxDb) then return nil, "audio.minDb must be below audio.maxDb" end
305
+ if acs.channel and int(acs.channel) > int(api.GetMediaTrackInfo_Value(tr, "I_NCHAN")) then
306
+ return nil, "audio.channel " .. int(acs.channel) .. " is above the track's " .. int(api.GetMediaTrackInfo_Value(tr, "I_NCHAN")) .. " channels"
307
+ end
308
+ if acs.direction ~= nil and not index_of({ -1, 0, 1 }, int(acs.direction)) then return nil, "audio.direction must be -1, 0 or 1" end
309
+ add("acs.active", 1)
310
+ if acs.attackMs ~= nil then add("acs.attack", tonumber(acs.attackMs)) end
311
+ if acs.releaseMs ~= nil then add("acs.release", tonumber(acs.releaseMs)) end
312
+ if acs.minDb ~= nil then add("acs.dblo", tonumber(acs.minDb)) end
313
+ if acs.maxDb ~= nil then add("acs.dbhi", tonumber(acs.maxDb)) end
314
+ if acs.strength ~= nil then add("acs.strength", tonumber(acs.strength)) end
315
+ if acs.channel ~= nil then add("acs.chan", int(acs.channel) - 1) end
316
+ if acs.direction ~= nil then add("acs.dir", int(acs.direction)) end
317
+ end
318
+ end
319
+
320
+ local link = spec.link
321
+ if type(link) == "table" then
322
+ if link.enabled == false then add("plink.active", 0) else
323
+ local src_fx, src_param = int(link.fxIndex), int(link.paramIndex)
324
+ local count = api.TrackFX_GetCount(tr)
325
+ if not src_fx or src_fx < 0 or src_fx >= count then return nil, "link.fxIndex " .. tostring(link.fxIndex) .. " out of range (track has " .. count .. " FX)" end
326
+ local n = api.TrackFX_GetNumParams(tr, src_fx)
327
+ if not src_param or src_param < 0 or src_param >= n then return nil, "link.paramIndex " .. tostring(link.paramIndex) .. " out of range (source FX has " .. n .. " parameters)" end
328
+ if src_fx == fx and src_param == param then return nil, "a parameter cannot be linked to itself" end
329
+ ok, err = check_range(link.scale, -10, 10, "link.scale")
330
+ if not ok then return nil, err end
331
+ ok, err = check_range(link.offset, -1, 1, "link.offset")
332
+ if not ok then return nil, err end
333
+ add("plink.active", 1)
334
+ add("plink.effect", src_fx)
335
+ add("plink.param", src_param)
336
+ if link.scale ~= nil then add("plink.scale", tonumber(link.scale)) end
337
+ if link.offset ~= nil then add("plink.offset", tonumber(link.offset)) end
338
+ end
339
+ end
340
+ return w
341
+ end
342
+
343
+ function M.set_modulation(api, p, spec)
344
+ local tr, fx = get_fx(api, p)
345
+ if not tr then return nil, fx end
346
+ if type(spec) ~= "table" then return nil, "No modulation given: pass enabled, baseline, lfo, audio and/or link" end
347
+ local n = api.TrackFX_GetNumParams(tr, fx)
348
+ local param = int(p.paramIndex)
349
+ if not param or param < 0 or param >= n then
350
+ return nil, "Parameter index " .. tostring(p.paramIndex) .. " out of range (FX has " .. n .. " parameters)"
351
+ end
352
+ local writes, err = modulation_writes(api, tr, fx, param, spec)
353
+ if not writes then return nil, err .. " — nothing changed" end
354
+ local before = read_modulation(api, tr, fx, param)
355
+ return with_undo(api, "MCP: Set FX parameter modulation", -1, function()
356
+ for _, w in ipairs(writes) do
357
+ if not api.TrackFX_SetNamedConfigParm(tr, fx, w[1], w[2]) then
358
+ error("REAPER refused " .. w[1] .. " = " .. w[2] .. " (needs REAPER 6.37+)", 0)
359
+ end
360
+ end
361
+ local _, fx_name = api.TrackFX_GetFXName(tr, fx)
362
+ return {
363
+ trackIndex = int(p.trackIndex), fxIndex = fx, fxName = fx_name, paramIndex = param,
364
+ modulation = read_modulation(api, tr, fx, param) or { paramIndex = param, enabled = false },
365
+ before = before,
366
+ }
367
+ end)
368
+ end
369
+
370
+ -- -----------------------------------------------------------------------------
371
+ -- FX pin mappings
372
+ -- -----------------------------------------------------------------------------
373
+
374
+ -- The channels (1-based) a pin is connected to, from its two 32-bit masks
375
+ local function channels_of(lo, hi)
376
+ local out = {}
377
+ for b = 0, 31 do if (int(lo) or 0) & (1 << b) ~= 0 then out[#out + 1] = b + 1 end end
378
+ for b = 0, 31 do if (int(hi) or 0) & (1 << b) ~= 0 then out[#out + 1] = b + 33 end end
379
+ return out
380
+ end
381
+
382
+ local function masks_of(chans)
383
+ local lo, hi = 0, 0
384
+ for _, c in ipairs(chans) do
385
+ if c <= 32 then lo = lo | (1 << (c - 1)) else hi = hi | (1 << (c - 33)) end
386
+ end
387
+ return lo, hi
388
+ end
389
+
390
+ local function read_pins(api, tr, fx)
391
+ local _, ins, outs = api.TrackFX_GetIOSize(tr, fx)
392
+ ins, outs = math.max(int(ins) or 0, 0), math.max(int(outs) or 0, 0)
393
+ local function side(is_out, count)
394
+ local list = {}
395
+ for pin = 0, count - 1 do
396
+ local lo, hi = api.TrackFX_GetPinMappings(tr, fx, is_out and 1 or 0, pin)
397
+ local ok, name = false, nil
398
+ if api.TrackFX_GetNamedConfigParm then
399
+ ok, name = api.TrackFX_GetNamedConfigParm(tr, fx, (is_out and "out_pin_" or "in_pin_") .. pin)
400
+ end
401
+ list[#list + 1] = { pin = pin, name = ok and name ~= "" and name or nil, channels = channels_of(lo, hi) }
402
+ end
403
+ return list
404
+ end
405
+ local _, fx_name = api.TrackFX_GetFXName(tr, fx)
406
+ return {
407
+ fxName = fx_name, trackChannels = int(api.GetMediaTrackInfo_Value(tr, "I_NCHAN")),
408
+ inputs = side(false, ins), outputs = side(true, outs),
409
+ }
410
+ end
411
+
412
+ function M.get_pins(api, p)
413
+ local tr, fx = get_fx(api, p)
414
+ if not tr then return nil, fx end
415
+ local res = read_pins(api, tr, fx)
416
+ res.trackIndex, res.fxIndex = int(p.trackIndex), fx
417
+ return res
418
+ end
419
+
420
+ -- Checks a side's mapping: a list of channel lists by pin, or { pin = n, channels = {...} } objects
421
+ local function pin_writes(side, mapping, count, writes)
422
+ if mapping == nil then return 0 end
423
+ if type(mapping) ~= "table" then return nil, side .. " must be a list" end
424
+ local highest = 0
425
+ for i, m in ipairs(mapping) do
426
+ local pin, chans
427
+ if type(m) == "table" and m.channels ~= nil then pin, chans = int(m.pin), m.channels
428
+ else pin, chans = i - 1, m end
429
+ if not pin or pin < 0 or pin >= count then
430
+ return nil, side .. " pin " .. tostring(pin) .. " out of range (the FX has " .. count .. " " .. side .. " pins)"
431
+ end
432
+ if type(chans) ~= "table" then return nil, side .. " pin " .. pin .. ": channels must be a list" end
433
+ local list = {}
434
+ for _, c in ipairs(chans) do
435
+ local ch = int(c)
436
+ if not ch or ch < 1 or ch > 64 then return nil, side .. " pin " .. pin .. ": channel " .. tostring(c) .. " must be 1..64" end
437
+ list[#list + 1] = ch
438
+ if ch > highest then highest = ch end
439
+ end
440
+ writes[#writes + 1] = { side == "outputs" and 1 or 0, pin, list }
441
+ end
442
+ return highest
443
+ end
444
+
445
+ function M.set_pins(api, p, inputs, outputs)
446
+ local tr, fx = get_fx(api, p)
447
+ if not tr then return nil, fx end
448
+ if inputs == nil and outputs == nil and p.trackChannels == nil then return nil, "Give inputs, outputs and/or trackChannels" end
449
+ local _, ins, outs = api.TrackFX_GetIOSize(tr, fx)
450
+ local writes = {}
451
+ local hi_in, err = pin_writes("inputs", inputs, int(ins) or 0, writes)
452
+ if not hi_in then return nil, err .. " — nothing changed" end
453
+ local hi_out
454
+ hi_out, err = pin_writes("outputs", outputs, int(outs) or 0, writes)
455
+ if not hi_out then return nil, err .. " — nothing changed" end
456
+ local nchan = int(api.GetMediaTrackInfo_Value(tr, "I_NCHAN"))
457
+ local want = int(p.trackChannels)
458
+ if p.trackChannels ~= nil and (not want or want < 2 or want > 128 or want % 2 ~= 0) then
459
+ return nil, "trackChannels must be an even number 2..128 — nothing changed"
460
+ end
461
+ -- A pin can only use channels the track has: widen the track to the highest one used, counting the
462
+ -- pins this call leaves as they are, so an explicit trackChannels never narrows the track under them
463
+ local before = read_pins(api, tr, fx)
464
+ local rewritten = {}
465
+ for _, w in ipairs(writes) do rewritten[w[1] .. ":" .. w[2]] = true end
466
+ local kept = 0
467
+ for side, list in pairs({ [0] = before.inputs, [1] = before.outputs }) do
468
+ for _, pin in ipairs(list) do
469
+ if not rewritten[side .. ":" .. pin.pin] then
470
+ -- Only channels the track has: an untouched pin on a channel past its width (ReaComp's
471
+ -- sidechain pins default to 3/4) carries no audio and asks for no widening
472
+ for _, c in ipairs(pin.channels) do if c <= nchan then kept = math.max(kept, c) end end
473
+ end
474
+ end
475
+ end
476
+ local highest = math.max(hi_in, hi_out, kept)
477
+ local needed = highest + highest % 2
478
+ if want and want < needed then
479
+ return nil, "trackChannels " .. want .. " is below channel " .. highest .. " used by a pin — nothing changed"
480
+ end
481
+ local target = want or (needed > nchan and needed or nil)
482
+
483
+ return with_undo(api, "MCP: Set FX pins", -1, function()
484
+ if target and target ~= nchan then api.SetMediaTrackInfo_Value(tr, "I_NCHAN", target) end
485
+ for _, w in ipairs(writes) do
486
+ local lo, hi = masks_of(w[3])
487
+ if not api.TrackFX_SetPinMappings(tr, fx, w[1], w[2], lo, hi) then
488
+ error("REAPER refused the mapping of " .. (w[1] == 1 and "output" or "input") .. " pin " .. w[2], 0)
489
+ end
490
+ end
491
+ local res = read_pins(api, tr, fx)
492
+ res.trackIndex, res.fxIndex = int(p.trackIndex), fx
493
+ res.before = before
494
+ if target and target ~= nchan then res.trackChannelsBefore = nchan end
495
+ return res
496
+ end)
497
+ end
498
+
499
+ -- -----------------------------------------------------------------------------
500
+ -- Monitoring FX (master track, index + 0x1000000; global to REAPER, not saved in the project)
501
+ -- -----------------------------------------------------------------------------
502
+
503
+ local function monitor_chain(api)
504
+ local m = api.GetMasterTrack(0)
505
+ local list = {}
506
+ for i = 0, api.TrackFX_GetRecCount(m) - 1 do
507
+ local idx = M.MONITOR + i
508
+ local _, name = api.TrackFX_GetFXName(m, idx)
509
+ list[#list + 1] = {
510
+ index = i, name = name, enabled = api.TrackFX_GetEnabled(m, idx),
511
+ offline = api.TrackFX_GetOffline and api.TrackFX_GetOffline(m, idx) or false,
512
+ }
513
+ end
514
+ return list
515
+ end
516
+
517
+ function M.list_monitoring_fx(api)
518
+ local fx = monitor_chain(api)
519
+ return { fx = fx, count = #fx }
520
+ end
521
+
522
+ local function monitor_index(api, v)
523
+ local i = int(v)
524
+ local count = api.TrackFX_GetRecCount(api.GetMasterTrack(0))
525
+ if not i or i < 0 or i >= count then return nil, "Monitoring FX index " .. tostring(v) .. " out of range (" .. count .. " monitoring FX)" end
526
+ return i
527
+ end
528
+
529
+ -- Normalized parameter writes [{paramIndex, value}] checked against an FX
530
+ local function param_writes(api, tr, idx, params)
531
+ if params == nil then return {} end
532
+ if type(params) ~= "table" then return nil, "params must be a list of {paramIndex, value}" end
533
+ local n = api.TrackFX_GetNumParams(tr, idx)
534
+ local out = {}
535
+ for _, prm in ipairs(params) do
536
+ local pi, v = int(prm.paramIndex), tonumber(prm.value)
537
+ if not pi or pi < 0 or pi >= n then return nil, "paramIndex " .. tostring(prm.paramIndex) .. " out of range (FX has " .. n .. " parameters)" end
538
+ if not v or v < 0 or v > 1 then return nil, "value for parameter " .. pi .. " must be 0..1 (normalized)" end
539
+ out[#out + 1] = { pi, v }
540
+ end
541
+ return out
542
+ end
543
+
544
+ function M.add_monitoring_fx(api, p, params)
545
+ if type(p.fxName) ~= "string" or p.fxName == "" then return nil, "fxName is required" end
546
+ local m = api.GetMasterTrack(0)
547
+ local before = monitor_chain(api)
548
+ return with_undo(api, "MCP: Add monitoring FX", -1, function()
549
+ local i = api.TrackFX_AddByName(m, p.fxName, true, -1)
550
+ if i < 0 then return nil, "FX not found: " .. p.fxName end
551
+ i = i >= M.MONITOR and i - M.MONITOR or i
552
+ local idx = M.MONITOR + i
553
+ local writes, err = param_writes(api, m, idx, params)
554
+ if not writes then
555
+ api.TrackFX_Delete(m, idx)
556
+ return nil, err .. " — nothing changed"
557
+ end
558
+ for _, w in ipairs(writes) do api.TrackFX_SetParamNormalized(m, idx, w[1], w[2]) end
559
+ local _, name = api.TrackFX_GetFXName(m, idx)
560
+ return { fxIndex = i, name = name, fx = monitor_chain(api), before = before }
561
+ end)
562
+ end
563
+
564
+ function M.set_monitoring_fx(api, p, params)
565
+ local i, err = monitor_index(api, p.fxIndex)
566
+ if not i then return nil, err end
567
+ local m = api.GetMasterTrack(0)
568
+ local idx = M.MONITOR + i
569
+ local writes
570
+ writes, err = param_writes(api, m, idx, params)
571
+ if not writes then return nil, err .. " — nothing changed" end
572
+ if p.enabled == nil and p.offline == nil and #writes == 0 then return nil, "Give enabled, offline and/or params" end
573
+ local before = monitor_chain(api)
574
+ return with_undo(api, "MCP: Set monitoring FX", -1, function()
575
+ if p.enabled ~= nil then api.TrackFX_SetEnabled(m, idx, p.enabled and true or false) end
576
+ if p.offline ~= nil and api.TrackFX_SetOffline then api.TrackFX_SetOffline(m, idx, p.offline and true or false) end
577
+ for _, w in ipairs(writes) do api.TrackFX_SetParamNormalized(m, idx, w[1], w[2]) end
578
+ local values = {}
579
+ for _, w in ipairs(writes) do
580
+ local _, text = api.TrackFX_GetFormattedParamValue(m, idx, w[1])
581
+ values[#values + 1] = { paramIndex = w[1], value = round(api.TrackFX_GetParamNormalized(m, idx, w[1]), 4), formatted = text }
582
+ end
583
+ return { fxIndex = i, fx = monitor_chain(api), params = values, before = before }
584
+ end)
585
+ end
586
+
587
+ function M.remove_monitoring_fx(api, p)
588
+ local i, err = monitor_index(api, p.fxIndex)
589
+ if not i then return nil, err end
590
+ local m = api.GetMasterTrack(0)
591
+ local before = monitor_chain(api)
592
+ return with_undo(api, "MCP: Remove monitoring FX", -1, function()
593
+ local removed = before[i + 1].name
594
+ api.TrackFX_Delete(m, M.MONITOR + i)
595
+ return { removed = removed, fx = monitor_chain(api), before = before }
596
+ end)
597
+ end
598
+
599
+ -- -----------------------------------------------------------------------------
600
+ -- Freeze and render in place (native actions on a selection that is put back afterwards)
601
+ -- -----------------------------------------------------------------------------
602
+
603
+ -- How many freezes are stacked on a track (each freeze adds a <FREEZE block)
604
+ local function freeze_depth(api, tr)
605
+ local ok, chunk = api.GetTrackStateChunk(tr, "", false)
606
+ if not ok or type(chunk) ~= "string" then return 0 end
607
+ local n = 0
608
+ for _ in chunk:gmatch("\n%s*<FREEZE") do n = n + 1 end
609
+ if chunk:sub(1, 7) == "<FREEZE" then n = n + 1 end
610
+ return n
611
+ end
612
+
613
+ local function track_state(api, tr)
614
+ return {
615
+ trackIndex = track_index(api, tr), name = track_name(api, tr),
616
+ frozen = freeze_depth(api, tr), items = api.CountTrackMediaItems(tr), fx = api.TrackFX_GetCount(tr),
617
+ }
618
+ end
619
+
620
+ local function save_track_selection(api)
621
+ local sel = {}
622
+ for i = 0, api.CountTracks(0) - 1 do
623
+ local tr = api.GetTrack(0, i)
624
+ sel[#sel + 1] = { tr, api.IsTrackSelected(tr) }
625
+ end
626
+ return sel
627
+ end
628
+
629
+ local function run_on_tracks(api, tracks, action)
630
+ local sel = save_track_selection(api)
631
+ for _, s in ipairs(sel) do api.SetTrackSelected(s[1], false) end
632
+ for _, tr in ipairs(tracks) do api.SetTrackSelected(tr, true) end
633
+ api.Main_OnCommand(action, 0)
634
+ for _, s in ipairs(sel) do api.SetTrackSelected(s[1], s[2]) end
635
+ end
636
+
637
+ function M.freeze_tracks(api, resolve, p)
638
+ local mode = p.mode or "stereo"
639
+ local action = M.FREEZE_ACTIONS[mode]
640
+ if not action then return nil, "Unknown mode " .. tostring(mode) .. ". Use stereo, mono or multichannel" end
641
+ local tracks, err = resolve_tracks(api, resolve, p.tracks, false)
642
+ if not tracks then return nil, err end
643
+ local before = {}
644
+ for i, tr in ipairs(tracks) do before[i] = track_state(api, tr) end
645
+ return with_undo(api, "MCP: Freeze tracks (" .. mode .. ")", -1, function()
646
+ run_on_tracks(api, tracks, action)
647
+ local rows = {}
648
+ for i, tr in ipairs(tracks) do
649
+ rows[i] = track_state(api, tr)
650
+ rows[i].before = before[i]
651
+ end
652
+ return { mode = mode, tracks = rows }
653
+ end)
654
+ end
655
+
656
+ function M.unfreeze_tracks(api, resolve, p)
657
+ local tracks, err = resolve_tracks(api, resolve, p.tracks, false)
658
+ if not tracks then return nil, err end
659
+ local before, idle = {}, {}
660
+ for i, tr in ipairs(tracks) do
661
+ before[i] = track_state(api, tr)
662
+ if before[i].frozen == 0 then idle[#idle + 1] = before[i].name end
663
+ end
664
+ if #idle > 0 then return nil, "Not frozen: " .. table.concat(idle, ", ") .. " — nothing changed" end
665
+ return with_undo(api, "MCP: Unfreeze tracks", -1, function()
666
+ run_on_tracks(api, tracks, M.UNFREEZE_ACTION)
667
+ local rows = {}
668
+ for i, tr in ipairs(tracks) do
669
+ rows[i] = track_state(api, tr)
670
+ rows[i].before = before[i]
671
+ end
672
+ return { tracks = rows }
673
+ end)
674
+ end
675
+
676
+ local function item_state(api, it, ref)
677
+ local take = api.GetActiveTake(it)
678
+ local tname = ""
679
+ if take then tname = select(2, api.GetSetMediaItemTakeInfo_String(take, "P_NAME", "", false)) end
680
+ return { trackIndex = ref.trackIndex, itemIndex = ref.itemIndex, takes = api.CountTakes(it), activeTake = tname }
681
+ end
682
+
683
+ function M.apply_fx_to_items(api, p, items)
684
+ local mode = p.mode or "stereo"
685
+ local action = M.APPLY_FX_ACTIONS[mode]
686
+ if not action then return nil, "Unknown mode " .. tostring(mode) .. ". Use stereo, mono or multichannel" end
687
+ if type(items) ~= "table" or #items == 0 then return nil, "items must be a non-empty list of {trackIndex, itemIndex}" end
688
+ if #items > M.MAX_ITEMS then return nil, "at most " .. M.MAX_ITEMS .. " items per call" end
689
+ local targets = {}
690
+ for _, ref in ipairs(items) do
691
+ local tr = api.GetTrack(0, int(ref.trackIndex) or -2)
692
+ if not tr then return nil, "Track " .. tostring(ref.trackIndex) .. " not found — nothing changed" end
693
+ local it = api.GetTrackMediaItem(tr, int(ref.itemIndex) or -1)
694
+ if not it then return nil, "Item " .. tostring(ref.itemIndex) .. " not found on track " .. tostring(ref.trackIndex) .. " — nothing changed" end
695
+ targets[#targets + 1] = { it = it, ref = { trackIndex = int(ref.trackIndex), itemIndex = int(ref.itemIndex) } }
696
+ end
697
+ local before = {}
698
+ for i, t in ipairs(targets) do before[i] = item_state(api, t.it, t.ref) end
699
+ return with_undo(api, "MCP: Apply FX to items (" .. mode .. ")", -1, function()
700
+ local sel = {}
701
+ for i = 0, api.CountMediaItems(0) - 1 do
702
+ local it = api.GetMediaItem(0, i)
703
+ sel[#sel + 1] = { it, api.IsMediaItemSelected(it) }
704
+ end
705
+ for _, s in ipairs(sel) do api.SetMediaItemSelected(s[1], false) end
706
+ for _, t in ipairs(targets) do api.SetMediaItemSelected(t.it, true) end
707
+ api.Main_OnCommand(action, 0)
708
+ for _, s in ipairs(sel) do api.SetMediaItemSelected(s[1], s[2]) end
709
+ api.UpdateArrange()
710
+ local rows = {}
711
+ for i, t in ipairs(targets) do
712
+ rows[i] = item_state(api, t.it, t.ref)
713
+ rows[i].before = before[i]
714
+ end
715
+ return { mode = mode, items = rows }
716
+ end)
717
+ end
718
+
719
+ -- -----------------------------------------------------------------------------
720
+ -- Stretch markers
721
+ -- -----------------------------------------------------------------------------
722
+
723
+ local function audio_take(api, p)
724
+ local tr = api.GetTrack(0, int(p.trackIndex) or -2)
725
+ if not tr then return nil, "Track " .. tostring(p.trackIndex) .. " not found" end
726
+ local it = api.GetTrackMediaItem(tr, int(p.itemIndex) or -1)
727
+ if not it then return nil, "Item " .. tostring(p.itemIndex) .. " not found on track " .. tostring(p.trackIndex) end
728
+ local take = api.GetActiveTake(it)
729
+ if not take then return nil, "The item has no take" end
730
+ if api.TakeIsMIDI(take) then return nil, "Stretch markers need an audio item; this one is MIDI" end
731
+ return { track = tr, item = it, take = take,
732
+ pos = api.GetMediaItemInfo_Value(it, "D_POSITION"), len = api.GetMediaItemInfo_Value(it, "D_LENGTH"),
733
+ offs = api.GetMediaItemTakeInfo_Value(take, "D_STARTOFFS"), rate = api.GetMediaItemTakeInfo_Value(take, "D_PLAYRATE") }
734
+ end
735
+
736
+ -- A stretch marker's pos is take time at play rate 1 (seen live, REAPER 7.40: a marker added
737
+ -- at pos 0.4 on a 2x item with offset 0.5 reads source 0.9). The tools speak item seconds,
738
+ -- so positions are item seconds x play rate on the way in and / play rate on the way out.
739
+ local function read_markers(api, t)
740
+ local out = {}
741
+ for i = 0, api.GetTakeNumStretchMarkers(t.take) - 1 do
742
+ local _, pos, src = api.GetTakeStretchMarker(t.take, i)
743
+ local slope = api.GetTakeStretchMarkerSlope and api.GetTakeStretchMarkerSlope(t.take, i) or 0
744
+ out[#out + 1] = { index = i, position = round(pos / t.rate, 4), sourcePosition = round(src, 4), slope = round(slope, 4) }
745
+ end
746
+ return out
747
+ end
748
+
749
+ function M.add_stretch_markers(api, p, markers)
750
+ local t, err = audio_take(api, p)
751
+ if not t then return nil, err end
752
+ if type(markers) ~= "table" or #markers == 0 then return nil, "markers must be a non-empty list of {position, sourcePosition?}" end
753
+ local have = api.GetTakeNumStretchMarkers(t.take)
754
+ if have + #markers > M.MAX_STRETCH_MARKERS then
755
+ return nil, "at most " .. M.MAX_STRETCH_MARKERS .. " stretch markers per item (it has " .. have .. ")"
756
+ end
757
+ for i, m in ipairs(markers) do
758
+ local pos = tonumber(m.position)
759
+ if not pos or pos < 0 or pos > t.len then
760
+ return nil, "marker " .. i .. ": position " .. tostring(m.position) .. " must be 0.." .. round(t.len, 3) .. " (seconds from the item start) — nothing changed"
761
+ end
762
+ if m.sourcePosition ~= nil and not tonumber(m.sourcePosition) then
763
+ return nil, "marker " .. i .. ": sourcePosition must be a number — nothing changed"
764
+ end
765
+ end
766
+ local before = read_markers(api, t)
767
+ return with_undo(api, "MCP: Add stretch markers", 4, function()
768
+ for _, m in ipairs(markers) do
769
+ local pos = tonumber(m.position) * t.rate
770
+ if m.sourcePosition ~= nil then api.SetTakeStretchMarker(t.take, -1, pos, tonumber(m.sourcePosition))
771
+ else api.SetTakeStretchMarker(t.take, -1, pos) end
772
+ end
773
+ api.UpdateItemInProject(t.item)
774
+ return { trackIndex = int(p.trackIndex), itemIndex = int(p.itemIndex), length = round(t.len, 4),
775
+ added = #markers, markers = read_markers(api, t), before = before }
776
+ end)
777
+ end
778
+
779
+ -- Replaces every marker of a take with `list` ({pos in take time at rate 1, src, slope?}, sorted).
780
+ local function write_markers(api, t, list)
781
+ for i = api.GetTakeNumStretchMarkers(t.take) - 1, 0, -1 do api.DeleteTakeStretchMarkers(t.take, i) end
782
+ for _, m in ipairs(list) do api.SetTakeStretchMarker(t.take, -1, m.pos, m.src) end
783
+ if api.SetTakeStretchMarkerSlope then
784
+ for i, m in ipairs(list) do
785
+ if m.slope and m.slope ~= 0 then api.SetTakeStretchMarkerSlope(t.take, i - 1, m.slope) end
786
+ end
787
+ end
788
+ end
789
+
790
+ -- Edits existing markers by index: {index, position?, sourcePosition?, slope?}. Positions are item
791
+ -- seconds. Every edit is checked against the whole list first (an edit that folds time, i.e. puts a
792
+ -- marker out of order in position or source, refuses the call); markers are then rewritten in one undo point.
793
+ function M.edit_stretch_markers(api, p, edits)
794
+ local t, err = audio_take(api, p)
795
+ if not t then return nil, err end
796
+ if type(edits) ~= "table" or #edits == 0 then
797
+ return nil, "edits must be a non-empty list of {index, position?, sourcePosition?, slope?}"
798
+ end
799
+ local have = api.GetTakeNumStretchMarkers(t.take)
800
+ local list = {}
801
+ for i = 0, have - 1 do
802
+ local _, pos, src = api.GetTakeStretchMarker(t.take, i)
803
+ list[i + 1] = { pos = pos, src = src, slope = api.GetTakeStretchMarkerSlope and api.GetTakeStretchMarkerSlope(t.take, i) or 0 }
804
+ end
805
+ local seen = {}
806
+ for n, e in ipairs(edits) do
807
+ local idx = int(e.index)
808
+ if idx == nil or idx < 0 or idx >= have then
809
+ return nil, "edit " .. n .. ": marker " .. tostring(e.index) .. " not found (the item has " .. have .. ") — nothing changed"
810
+ end
811
+ if seen[idx] then return nil, "edit " .. n .. ": marker " .. idx .. " is edited twice — nothing changed" end
812
+ seen[idx] = true
813
+ if e.position == nil and e.sourcePosition == nil and e.slope == nil then
814
+ return nil, "edit " .. n .. ": give position, sourcePosition or slope — nothing changed"
815
+ end
816
+ local m = list[idx + 1]
817
+ if e.position ~= nil then
818
+ local pos = tonumber(e.position)
819
+ if not pos or pos < 0 or pos > t.len then
820
+ return nil, "edit " .. n .. ": position " .. tostring(e.position) .. " must be 0.." .. round(t.len, 3) .. " (seconds from the item start) — nothing changed"
821
+ end
822
+ m.pos = pos * t.rate
823
+ end
824
+ if e.sourcePosition ~= nil then
825
+ local src = tonumber(e.sourcePosition)
826
+ if not src then return nil, "edit " .. n .. ": sourcePosition must be a number — nothing changed" end
827
+ m.src = src
828
+ end
829
+ if e.slope ~= nil then
830
+ local slope = tonumber(e.slope)
831
+ if not slope or slope < -1 or slope > 1 then return nil, "edit " .. n .. ": slope must be -1..1 — nothing changed" end
832
+ m.slope = slope
833
+ end
834
+ end
835
+ table.sort(list, function(a, b) return a.pos < b.pos end)
836
+ for i = 2, #list do
837
+ if list[i].pos <= list[i - 1].pos + 1e-6 then
838
+ return nil, "two markers would sit at the same position (" .. round(list[i].pos / t.rate, 4) .. " s) — nothing changed"
839
+ end
840
+ if list[i].src <= list[i - 1].src + 1e-6 then
841
+ return nil, "the edit would fold time: marker sources must keep increasing with position — nothing changed"
842
+ end
843
+ end
844
+ local before = read_markers(api, t)
845
+ return with_undo(api, "MCP: Edit stretch markers", 4, function()
846
+ write_markers(api, t, list)
847
+ api.UpdateItemInProject(t.item)
848
+ return { trackIndex = int(p.trackIndex), itemIndex = int(p.itemIndex), length = round(t.len, 4),
849
+ edited = #edits, markers = read_markers(api, t), before = before }
850
+ end)
851
+ end
852
+
853
+ -- Grid line nearest a project time (grid in quarter notes, swing like quantize_midi)
854
+ local function nearest_line(api, time, grid, swing)
855
+ local qn = api.TimeMap2_timeToQN(0, time)
856
+ local pair = grid * 2
857
+ local base = math.floor(qn / pair) * pair
858
+ local best, best_d
859
+ for k = -1, 1 do
860
+ local b = base + k * pair
861
+ for _, line in ipairs({ b, b + grid * (1 + swing) }) do
862
+ local d = math.abs(line - qn)
863
+ if not best_d or d < best_d then best, best_d = line, d end
864
+ end
865
+ end
866
+ return api.TimeMap2_QNToTime(0, best), best_d
867
+ end
868
+
869
+ -- Average and worst of a list of offsets in seconds, as ms
870
+ local function offset_stats(list_s)
871
+ if #list_s == 0 then return { avgMs = 0, maxMs = 0 } end
872
+ local sum, mx = 0, 0
873
+ for _, v in ipairs(list_s) do sum = sum + v; if v > mx then mx = v end end
874
+ return { avgMs = round(sum / #list_s * 1000, 1), maxMs = round(mx * 1000, 1) }
875
+ end
876
+
877
+ -- Places one stretch marker per hit and moves it toward the nearest grid line. `hits` are
878
+ -- source-time seconds (as read from the item's source file); existing markers are replaced.
879
+ function M.quantize_stretch(api, p, hits)
880
+ local t, err = audio_take(api, p)
881
+ if not t then return nil, err end
882
+ local grid = tonumber(p.grid)
883
+ if not grid or grid <= 0 or grid > 16 then return nil, "grid must be > 0 and <= 16 quarter notes" end
884
+ local strength = tonumber(p.strength or 1)
885
+ if not strength or strength < 0 or strength > 1 then return nil, "strength must be 0..1" end
886
+ local swing = tonumber(p.swing or 0)
887
+ if not swing or swing < 0 or swing > 0.9 then return nil, "swing must be 0..0.9" end
888
+ local window = tonumber(p.window or 1)
889
+ if not window or window <= 0 or window > 1 then return nil, "window must be > 0 and <= 1" end
890
+ if type(hits) ~= "table" then return nil, "hits must be a list of source times" end
891
+ if #hits > M.MAX_STRETCH_MARKERS then return nil, "at most " .. M.MAX_STRETCH_MARKERS .. " hits per item" end
892
+ -- Hits inside the item, as item-relative positions, in order
893
+ local edge = 0.005
894
+ local list = {}
895
+ for _, s in ipairs(hits) do
896
+ local src = tonumber(s)
897
+ if src then
898
+ local pos = (src - t.offs) / t.rate
899
+ if pos > edge and pos < t.len - edge then list[#list + 1] = { src = src, pos = pos } end
900
+ end
901
+ end
902
+ table.sort(list, function(a, b) return a.pos < b.pos end)
903
+ -- No hit to place: replacing the markers would only wipe the ones the item has
904
+ if #list == 0 then return nil, "No hits inside the item — nothing changed" end
905
+
906
+ local before = read_markers(api, t)
907
+ -- With swing the lines are up to (1 + swing) steps apart, so window 1 still reaches every hit (as quantize_midi)
908
+ local reach = window * (1 + swing) * grid / 2
909
+ local placed, last_pos, last_line = {}, edge, nil
910
+ local moved, skipped, err_before, err_after = 0, 0, {}, {}
911
+ for _, h in ipairs(list) do
912
+ local time = t.pos + h.pos
913
+ local line_time, dist = nearest_line(api, time, grid, swing)
914
+ local new_pos = h.pos
915
+ if dist <= reach + 1e-9 then
916
+ new_pos = h.pos + (line_time - time) * strength
917
+ end
918
+ -- Two hits on one grid line, or a move that would cross the previous marker, would fold time
919
+ if (last_line and math.abs(line_time - last_line) < 1e-9 and dist <= reach) or new_pos <= last_pos + 0.001 or new_pos >= t.len - edge then
920
+ skipped = skipped + 1
921
+ else
922
+ placed[#placed + 1] = { pos = new_pos, src = h.src, from = h.pos }
923
+ if math.abs(new_pos - h.pos) > 1e-6 then moved = moved + 1 end
924
+ err_before[#err_before + 1] = math.abs(line_time - time)
925
+ err_after[#err_after + 1] = math.abs(line_time - (t.pos + new_pos))
926
+ last_pos = new_pos
927
+ if dist <= reach then last_line = line_time end
928
+ end
929
+ end
930
+ if #placed == 0 then return nil, "No hit could be placed (each would fold time or reach an item edge) — nothing changed" end
931
+
932
+ return with_undo(api, "MCP: Quantize audio", 4, function()
933
+ for i = api.GetTakeNumStretchMarkers(t.take) - 1, 0, -1 do api.DeleteTakeStretchMarkers(t.take, i) end
934
+ -- Anchors at both ends keep the item's length and the audio outside the hits in place
935
+ api.SetTakeStretchMarker(t.take, -1, 0, t.offs)
936
+ for _, m in ipairs(placed) do api.SetTakeStretchMarker(t.take, -1, m.pos * t.rate, m.src) end
937
+ api.SetTakeStretchMarker(t.take, -1, t.len * t.rate, t.offs + t.len * t.rate)
938
+ api.UpdateItemInProject(t.item)
939
+ local hits_out = {}
940
+ for i = 1, math.min(#placed, 256) do hits_out[i] = { round(placed[i].from, 4), round(placed[i].pos, 4) } end
941
+ return {
942
+ trackIndex = int(p.trackIndex), itemIndex = int(p.itemIndex), position = round(t.pos, 4), length = round(t.len, 4),
943
+ grid = grid, strength = strength, hits = #list, placed = #placed, moved = moved, skipped = skipped,
944
+ replacedMarkers = #before, offsetBefore = offset_stats(err_before), offsetAfter = offset_stats(err_after),
945
+ moves = hits_out, more = math.max(0, #placed - 256),
946
+ }
947
+ end)
948
+ end
949
+
950
+ -- Moves a double's hits onto a guide's. `pairs` are {src, to, extra?}: src is a hit's time in the
951
+ -- double's source file, `to` the project time it should land on (the guide's hit). `extra` pairs
952
+ -- (a consonant's rigid tail) move with their hit but do not count in the offsets. With strength < 1 each
953
+ -- hit moves that fraction of the way. The item's existing markers are replaced; anchors at both ends
954
+ -- keep its length. dryRun returns the plan and writes nothing.
955
+ function M.align_stretch(api, p, pairs)
956
+ local t, err = audio_take(api, p)
957
+ if not t then return nil, err end
958
+ local strength = tonumber(p.strength or 1)
959
+ if not strength or strength <= 0 or strength > 1 then return nil, "strength must be > 0 and <= 1" end
960
+ if type(pairs) ~= "table" then return nil, "pairs must be a list of {src, to}" end
961
+ if #pairs > M.MAX_STRETCH_MARKERS then return nil, "at most " .. M.MAX_STRETCH_MARKERS .. " pairs per item" end
962
+ local edge = 0.005
963
+ local list = {}
964
+ for n, pr in ipairs(pairs) do
965
+ local src, to = tonumber(pr.src), tonumber(pr.to)
966
+ if not src or not to then return nil, "pair " .. n .. ": src and to must be numbers — nothing changed" end
967
+ local pos = (src - t.offs) / t.rate
968
+ if pos > edge and pos < t.len - edge then list[#list + 1] = { src = src, pos = pos, target = to - t.pos, extra = pr.extra == true } end
969
+ end
970
+ table.sort(list, function(a, b) return a.pos < b.pos end)
971
+ if #list == 0 then return nil, "No hits inside the item — nothing changed" end
972
+
973
+ local before = read_markers(api, t)
974
+ -- Hits are counted apart from consonant tails (extra pairs), which only ride along with their hit
975
+ local placed, hits, last_pos, skipped, moved, tails = {}, {}, edge, 0, 0, 0
976
+ local err_before, err_after = {}, {}
977
+ for _, h in ipairs(list) do
978
+ local new_pos = h.pos + (h.target - h.pos) * strength
979
+ -- A move that would cross the previous marker or reach the item end would fold time
980
+ if new_pos <= last_pos + 0.001 or new_pos >= t.len - edge then
981
+ if not h.extra then skipped = skipped + 1 end
982
+ else
983
+ local m = { pos = new_pos, src = h.src, from = h.pos, to = h.target }
984
+ placed[#placed + 1] = m
985
+ if h.extra then
986
+ tails = tails + 1
987
+ else
988
+ hits[#hits + 1] = m
989
+ if math.abs(new_pos - h.pos) > 1e-6 then moved = moved + 1 end
990
+ err_before[#err_before + 1] = math.abs(h.target - h.pos)
991
+ err_after[#err_after + 1] = math.abs(h.target - new_pos)
992
+ end
993
+ last_pos = new_pos
994
+ end
995
+ end
996
+ if #hits == 0 then return nil, "No hit could be placed (each would fold time or reach an item edge) — nothing changed" end
997
+
998
+ local moves = {}
999
+ for i = 1, math.min(#hits, 256) do moves[i] = { round(hits[i].from, 4), round(hits[i].pos, 4) } end
1000
+ local real = 0
1001
+ for _, h in ipairs(list) do if not h.extra then real = real + 1 end end
1002
+ local result = {
1003
+ trackIndex = int(p.trackIndex), itemIndex = int(p.itemIndex), position = round(t.pos, 4), length = round(t.len, 4),
1004
+ strength = strength, pairs = real, placed = #hits, tails = tails, moved = moved, skipped = skipped,
1005
+ replacedMarkers = #before, offsetBefore = offset_stats(err_before), offsetAfter = offset_stats(err_after),
1006
+ moves = moves, more = math.max(0, #hits - 256), dryRun = p.dryRun == true,
1007
+ guideTrackIndex = int(p.guideTrackIndex), guideItemIndex = int(p.guideItemIndex),
1008
+ }
1009
+ if p.dryRun == true then return result end
1010
+
1011
+ return with_undo(api, "MCP: Align audio to guide", 4, function()
1012
+ local out = { { pos = 0, src = t.offs } }
1013
+ for _, m in ipairs(placed) do out[#out + 1] = { pos = m.pos * t.rate, src = m.src } end
1014
+ out[#out + 1] = { pos = t.len * t.rate, src = t.offs + t.len * t.rate }
1015
+ write_markers(api, t, out)
1016
+ api.UpdateItemInProject(t.item)
1017
+ return result
1018
+ end)
1019
+ end
1020
+
1021
+ return M