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