@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,492 @@
|
|
|
1
|
+
-- mcp_render_lib.lua — renders tracks (post-FX) to temp WAVs for the analysis tools.
|
|
2
|
+
-- Pure logic over an injected ReaScript API (and an optional io/os table), unit-tested via
|
|
3
|
+
-- wasmoon against a fake. Loaded by mcp_bridge.lua.
|
|
4
|
+
--
|
|
5
|
+
-- REAPER can only render through its progress window, which takes focus, so the point of this
|
|
6
|
+
-- lib is to render as rarely as possible:
|
|
7
|
+
-- * a cache keeps the last renders (per track and range) and hands them out again until the
|
|
8
|
+
-- project changes: any command that writes (`note_write`) or any change REAPER's own
|
|
9
|
+
-- project state counter sees, a render older than TTL, or a file that is gone;
|
|
10
|
+
-- * several tracks go through ONE stem render when they do not feed each other;
|
|
11
|
+
-- * on macOS the app that had focus is brought back after the render.
|
|
12
|
+
-- The cache owns its files (the server never deletes them): it evicts the oldest past
|
|
13
|
+
-- MAX_ENTRIES / MAX_BYTES and `purge` removes the rest on exit.
|
|
14
|
+
|
|
15
|
+
local M = {}
|
|
16
|
+
|
|
17
|
+
M.PREFIX = "mcp_render_"
|
|
18
|
+
M.LEGACY_PREFIX = "mcp_aes_"
|
|
19
|
+
M.SAMPLE_RATE = 44100
|
|
20
|
+
M.MAX_ENTRIES = 12
|
|
21
|
+
M.MAX_BYTES = 1500 * 1024 * 1024
|
|
22
|
+
M.TTL = 600 -- seconds
|
|
23
|
+
M.MAX_TRACKS = 16
|
|
24
|
+
M.REAPER_BUNDLE = "com.cockos.reaper"
|
|
25
|
+
|
|
26
|
+
local epoch = 0 -- bumped by every command that writes: a parameter set through the API may not move REAPER's counter
|
|
27
|
+
local cache = {} -- oldest first: { key, path, bytes, at, stamp }
|
|
28
|
+
local passes = 0 -- makes every render's file names unique within one bridge run
|
|
29
|
+
|
|
30
|
+
local function num(v) return type(v) == "number" and v == v and v ~= math.huge and v ~= -math.huge end
|
|
31
|
+
local function int(v) return num(v) and v == math.floor(v) end
|
|
32
|
+
|
|
33
|
+
local function mkenv(e)
|
|
34
|
+
e = e or {}
|
|
35
|
+
local o = e.os or os
|
|
36
|
+
return { io = e.io or io, os = o, now = e.now or o.time }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
--- A command that may change what a render sounds like ran: every cached render is stale
|
|
40
|
+
function M.note_write() epoch = epoch + 1 end
|
|
41
|
+
|
|
42
|
+
--- Forget everything without touching files (tests)
|
|
43
|
+
function M.reset() epoch = 0; cache = {}; passes = 0 end
|
|
44
|
+
|
|
45
|
+
function M.cached_count() return #cache end
|
|
46
|
+
|
|
47
|
+
-- ---------------------------------------------------------------------------
|
|
48
|
+
-- Files
|
|
49
|
+
-- ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
local function file_info(env, path)
|
|
52
|
+
local f = env.io.open(path, "rb")
|
|
53
|
+
if not f then return nil end
|
|
54
|
+
local magic = f:read(4)
|
|
55
|
+
local size = 0
|
|
56
|
+
if f.seek then
|
|
57
|
+
local ok, s = pcall(f.seek, f, "end")
|
|
58
|
+
if ok and num(s) then size = s end
|
|
59
|
+
end
|
|
60
|
+
f:close()
|
|
61
|
+
return { wav = magic == "RIFF", magic = magic, bytes = size }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
local function remove_file(env, path) pcall(env.os.remove, path) end
|
|
65
|
+
|
|
66
|
+
local function temp_dir(reaper, env)
|
|
67
|
+
local dir
|
|
68
|
+
if reaper.GetTempPath then
|
|
69
|
+
dir = reaper.GetTempPath()
|
|
70
|
+
else
|
|
71
|
+
local t = env.os.tmpname()
|
|
72
|
+
dir = t:match("^(.+)[/\\][^/\\]+$") or "/tmp"
|
|
73
|
+
env.os.remove(t)
|
|
74
|
+
end
|
|
75
|
+
if dir:sub(-1) ~= "/" and dir:sub(-1) ~= "\\" then dir = dir .. "/" end
|
|
76
|
+
return dir
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
-- ---------------------------------------------------------------------------
|
|
80
|
+
-- Cache
|
|
81
|
+
-- ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
-- The project's state as the cache sees it: our write counter plus REAPER's own. nil when REAPER
|
|
84
|
+
-- cannot say, which turns the cache off.
|
|
85
|
+
local function stamp_parts(reaper)
|
|
86
|
+
if not reaper.GetProjectStateChangeCount then return nil end
|
|
87
|
+
return epoch, reaper.GetProjectStateChangeCount(0)
|
|
88
|
+
end
|
|
89
|
+
local function stamp_of(reaper)
|
|
90
|
+
local e, c = stamp_parts(reaper)
|
|
91
|
+
return e and (e .. ":" .. tostring(c)) or nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
local function drop(env, i)
|
|
95
|
+
local entry = table.remove(cache, i)
|
|
96
|
+
if entry then remove_file(env, entry.path) end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
-- Entries from another project state, past their TTL or whose file is gone
|
|
100
|
+
local function drop_stale(env, stamp)
|
|
101
|
+
local now = env.now()
|
|
102
|
+
for i = #cache, 1, -1 do
|
|
103
|
+
local e = cache[i]
|
|
104
|
+
if stamp == nil or e.stamp ~= stamp or now - e.at > M.TTL then
|
|
105
|
+
drop(env, i)
|
|
106
|
+
elseif not file_info(env, e.path) then
|
|
107
|
+
table.remove(cache, i)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
local function lookup(env, key)
|
|
113
|
+
for i, e in ipairs(cache) do
|
|
114
|
+
if e.key == key then
|
|
115
|
+
table.remove(cache, i)
|
|
116
|
+
cache[#cache + 1] = e -- most recently used last
|
|
117
|
+
return e
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
local function store(env, entry)
|
|
123
|
+
for i = #cache, 1, -1 do
|
|
124
|
+
if cache[i].key == entry.key then drop(env, i) end
|
|
125
|
+
end
|
|
126
|
+
cache[#cache + 1] = entry
|
|
127
|
+
local total = 0
|
|
128
|
+
for _, e in ipairs(cache) do total = total + e.bytes end
|
|
129
|
+
-- Never evict the render just made, even when it alone is over the budget
|
|
130
|
+
while #cache > 1 and (#cache > M.MAX_ENTRIES or total > M.MAX_BYTES) do
|
|
131
|
+
total = total - cache[1].bytes
|
|
132
|
+
drop(env, 1)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
--- Delete every cached render (bridge exit)
|
|
137
|
+
function M.purge(e)
|
|
138
|
+
local env = mkenv(e)
|
|
139
|
+
while #cache > 0 do drop(env, #cache) end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
--- Delete renders a previous bridge run left in the temp folder
|
|
143
|
+
function M.sweep(reaper, e)
|
|
144
|
+
local env = mkenv(e)
|
|
145
|
+
if not reaper.EnumerateFiles then return 0 end
|
|
146
|
+
local dir = temp_dir(reaper, env)
|
|
147
|
+
local doomed = {}
|
|
148
|
+
local i = 0
|
|
149
|
+
while true do
|
|
150
|
+
local name = reaper.EnumerateFiles(dir, i)
|
|
151
|
+
if not name then break end
|
|
152
|
+
if name:sub(-4) == ".wav" and (name:sub(1, #M.PREFIX) == M.PREFIX or name:sub(1, #M.LEGACY_PREFIX) == M.LEGACY_PREFIX) then
|
|
153
|
+
doomed[#doomed + 1] = name
|
|
154
|
+
end
|
|
155
|
+
i = i + 1
|
|
156
|
+
end
|
|
157
|
+
for _, name in ipairs(doomed) do remove_file(env, dir .. name) end
|
|
158
|
+
return #doomed
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
-- ---------------------------------------------------------------------------
|
|
162
|
+
-- Focus (macOS)
|
|
163
|
+
-- ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
local function is_mac(reaper)
|
|
166
|
+
local name = reaper.GetOS and reaper.GetOS() or ""
|
|
167
|
+
return name:find("^OSX") ~= nil or name:find("^macOS") ~= nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
local function current_front(reaper)
|
|
171
|
+
if not (is_mac(reaper) and reaper.ExecProcess) then return nil end
|
|
172
|
+
local ok, out = pcall(reaper.ExecProcess,
|
|
173
|
+
"/bin/sh -c 'lsappinfo info -only bundleid \"$(lsappinfo front)\"'", 1500)
|
|
174
|
+
if not ok or type(out) ~= "string" then return nil end
|
|
175
|
+
local id = out:match('"CFBundleIdentifier"%s*=%s*"([^"]+)"')
|
|
176
|
+
-- The id goes on a command line: only the characters a bundle id has
|
|
177
|
+
if not id or not id:match("^[%w%.%-_]+$") then return nil end
|
|
178
|
+
return id
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
--- Bundle id of the app that has focus, when it is not REAPER (nil: nothing to bring back)
|
|
182
|
+
function M.front_app(reaper)
|
|
183
|
+
local id = current_front(reaper)
|
|
184
|
+
return id ~= M.REAPER_BUNDLE and id or nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
--- Bring the app front_app saw back to the front, if REAPER (its render window) now has focus. Someone
|
|
188
|
+
--- who switched to another app during a long render is left where they went.
|
|
189
|
+
function M.restore_front(reaper, id)
|
|
190
|
+
if not id or not reaper.ExecProcess then return false end
|
|
191
|
+
if current_front(reaper) ~= M.REAPER_BUNDLE then return false end
|
|
192
|
+
local ok = pcall(reaper.ExecProcess, "/usr/bin/open -b " .. id, 2000)
|
|
193
|
+
return ok
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
-- ---------------------------------------------------------------------------
|
|
197
|
+
-- Render
|
|
198
|
+
-- ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
local function track_name(reaper, track, index, master)
|
|
201
|
+
if master then return "MASTER" end
|
|
202
|
+
local name = ({reaper.GetSetMediaTrackInfo_String(track, "P_NAME", "", false)})[2] or ""
|
|
203
|
+
if name == "" then name = "Track " .. (index + 1) end
|
|
204
|
+
return name
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
local function track_id(reaper, t)
|
|
208
|
+
if t.master then return "master" end
|
|
209
|
+
return reaper.GetTrackGUID and reaper.GetTrackGUID(t.track) or ("#" .. t.index)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
-- Two tracks feed each other (folder parent/child, a send between them): rendered together, the
|
|
213
|
+
-- one's stem could carry the other, so they never share a render
|
|
214
|
+
local function related(reaper, a, b)
|
|
215
|
+
local function ancestor(x, y)
|
|
216
|
+
if not reaper.GetParentTrack then return false end
|
|
217
|
+
local p = reaper.GetParentTrack(y)
|
|
218
|
+
local guard = 0
|
|
219
|
+
while p and guard < 64 do
|
|
220
|
+
if p == x then return true end
|
|
221
|
+
p = reaper.GetParentTrack(p)
|
|
222
|
+
guard = guard + 1
|
|
223
|
+
end
|
|
224
|
+
return false
|
|
225
|
+
end
|
|
226
|
+
local function sends(x, y)
|
|
227
|
+
if not (reaper.GetTrackNumSends and reaper.GetTrackSendInfo_Value) then return false end
|
|
228
|
+
for i = 0, reaper.GetTrackNumSends(x, 0) - 1 do
|
|
229
|
+
if reaper.GetTrackSendInfo_Value(x, 0, i, "P_DESTTRACK") == y then return true end
|
|
230
|
+
end
|
|
231
|
+
return false
|
|
232
|
+
end
|
|
233
|
+
return ancestor(a, b) or ancestor(b, a) or sends(a, b) or sends(b, a)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
local function capture(reaper)
|
|
237
|
+
local s = {}
|
|
238
|
+
local function str(key) return ({reaper.GetSetProjectInfo_String(0, key, "", false)})[2] or "" end
|
|
239
|
+
local function val(key) return reaper.GetSetProjectInfo(0, key, 0, false) end
|
|
240
|
+
s.file, s.pattern, s.format, s.format2 = str("RENDER_FILE"), str("RENDER_PATTERN"), str("RENDER_FORMAT"), str("RENDER_FORMAT2")
|
|
241
|
+
s.bounds, s.settings = val("RENDER_BOUNDSFLAG"), val("RENDER_SETTINGS")
|
|
242
|
+
s.start, s.stop = val("RENDER_STARTPOS"), val("RENDER_ENDPOS")
|
|
243
|
+
s.srate, s.channels = val("RENDER_SRATE"), val("RENDER_CHANNELS")
|
|
244
|
+
-- Delivery processing set_render_settings stores in the project: an analysis render must hear the raw mix
|
|
245
|
+
s.normalize, s.dither = val("RENDER_NORMALIZE"), val("RENDER_DITHER")
|
|
246
|
+
s.count = reaper.CountTracks(0)
|
|
247
|
+
s.solo, s.selected = {}, {}
|
|
248
|
+
for i = 0, s.count - 1 do
|
|
249
|
+
local t = reaper.GetTrack(0, i)
|
|
250
|
+
s.solo[i] = reaper.GetMediaTrackInfo_Value(t, "I_SOLO")
|
|
251
|
+
s.selected[i] = reaper.IsTrackSelected(t)
|
|
252
|
+
end
|
|
253
|
+
return s
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
local function restore(reaper, s)
|
|
257
|
+
reaper.GetSetProjectInfo_String(0, "RENDER_FILE", s.file, true)
|
|
258
|
+
reaper.GetSetProjectInfo_String(0, "RENDER_PATTERN", s.pattern, true)
|
|
259
|
+
reaper.GetSetProjectInfo_String(0, "RENDER_FORMAT", s.format, true)
|
|
260
|
+
reaper.GetSetProjectInfo_String(0, "RENDER_FORMAT2", s.format2, true)
|
|
261
|
+
reaper.GetSetProjectInfo(0, "RENDER_BOUNDSFLAG", s.bounds, true)
|
|
262
|
+
reaper.GetSetProjectInfo(0, "RENDER_SETTINGS", s.settings, true)
|
|
263
|
+
reaper.GetSetProjectInfo(0, "RENDER_STARTPOS", s.start, true)
|
|
264
|
+
reaper.GetSetProjectInfo(0, "RENDER_ENDPOS", s.stop, true)
|
|
265
|
+
reaper.GetSetProjectInfo(0, "RENDER_SRATE", s.srate, true)
|
|
266
|
+
reaper.GetSetProjectInfo(0, "RENDER_CHANNELS", s.channels, true)
|
|
267
|
+
reaper.GetSetProjectInfo(0, "RENDER_NORMALIZE", s.normalize, true)
|
|
268
|
+
reaper.GetSetProjectInfo(0, "RENDER_DITHER", s.dither, true)
|
|
269
|
+
for i = 0, s.count - 1 do
|
|
270
|
+
local t = reaper.GetTrack(0, i)
|
|
271
|
+
reaper.SetMediaTrackInfo_Value(t, "I_SOLO", s.solo[i] or 0)
|
|
272
|
+
reaper.SetTrackSelected(t, s.selected[i] or false)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
-- Output, bounds and format a render of the analysis tools always uses
|
|
277
|
+
local function apply_common(reaper, dir, start, stop)
|
|
278
|
+
reaper.GetSetProjectInfo_String(0, "RENDER_FILE", dir, true)
|
|
279
|
+
reaper.GetSetProjectInfo(0, "RENDER_BOUNDSFLAG", 0, true) -- custom time range
|
|
280
|
+
reaper.GetSetProjectInfo(0, "RENDER_STARTPOS", start, true)
|
|
281
|
+
reaper.GetSetProjectInfo(0, "RENDER_ENDPOS", stop, true)
|
|
282
|
+
-- WAV at 44100 Hz stereo, whatever the user last rendered. "evaw" is REAPER's WAV fourcc
|
|
283
|
+
reaper.GetSetProjectInfo_String(0, "RENDER_FORMAT", "evaw", true)
|
|
284
|
+
-- No secondary file: a format that cannot write at this rate (a video format a new project inherits)
|
|
285
|
+
-- stops the render on a modal error, which blocks the bridge until someone closes it
|
|
286
|
+
reaper.GetSetProjectInfo_String(0, "RENDER_FORMAT2", "", true)
|
|
287
|
+
reaper.GetSetProjectInfo(0, "RENDER_SRATE", M.SAMPLE_RATE, true)
|
|
288
|
+
reaper.GetSetProjectInfo(0, "RENDER_CHANNELS", 2, true)
|
|
289
|
+
reaper.GetSetProjectInfo(0, "RENDER_NORMALIZE", 0, true)
|
|
290
|
+
reaper.GetSetProjectInfo(0, "RENDER_DITHER", 0, true)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
local function solo_only(reaper, count, targets)
|
|
294
|
+
for i = 0, count - 1 do reaper.SetMediaTrackInfo_Value(reaper.GetTrack(0, i), "I_SOLO", 0) end
|
|
295
|
+
local first = true
|
|
296
|
+
for _, t in ipairs(targets) do
|
|
297
|
+
if not t.master then
|
|
298
|
+
reaper.SetMediaTrackInfo_Value(t.track, "I_SOLO", 1)
|
|
299
|
+
if first then reaper.SetOnlyTrackSelected(t.track); first = false else reaper.SetTrackSelected(t.track, true) end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
-- One REAPER render for a group: the master mix alone, one track, or several unrelated tracks as
|
|
305
|
+
-- stems of the selection. Sets t.path on every target it rendered; leaves the rest for a fallback.
|
|
306
|
+
local function render_group(reaper, env, ctx, group)
|
|
307
|
+
passes = passes + 1
|
|
308
|
+
local base = M.PREFIX .. ctx.id .. "_" .. passes
|
|
309
|
+
local master = group[1].master
|
|
310
|
+
-- Render mode: 0 = the master mix; 3 = stems of the SELECTED tracks only (2 lists no target in
|
|
311
|
+
-- REAPER 7 and silently renders the master mix instead)
|
|
312
|
+
reaper.GetSetProjectInfo(0, "RENDER_SETTINGS", master and 0 or 3, true)
|
|
313
|
+
solo_only(reaper, ctx.saved.count, group)
|
|
314
|
+
if #group == 1 then
|
|
315
|
+
reaper.GetSetProjectInfo_String(0, "RENDER_PATTERN", base, true)
|
|
316
|
+
group[1].expect = ctx.dir .. base .. ".wav"
|
|
317
|
+
else
|
|
318
|
+
-- One file per stem: the pattern names the track number, and the targets REAPER lists tell which file is whose
|
|
319
|
+
reaper.GetSetProjectInfo_String(0, "RENDER_PATTERN", base .. "_$tracknumber", true)
|
|
320
|
+
local listed = ({reaper.GetSetProjectInfo_String(0, "RENDER_TARGETS", "", false)})[2] or ""
|
|
321
|
+
local byNumber = {}
|
|
322
|
+
for target in listed:gmatch("[^;\n]+") do
|
|
323
|
+
local file = target:match("([^/\\]+)$") or target
|
|
324
|
+
local n = file:match("^" .. base:gsub("%p", "%%%0") .. "_(%d+)")
|
|
325
|
+
if n then byNumber[tonumber(n)] = target end
|
|
326
|
+
end
|
|
327
|
+
for _, t in ipairs(group) do
|
|
328
|
+
t.expect = byNumber[t.index + 1]
|
|
329
|
+
if not t.expect then return false end -- not what we predicted: nothing rendered, the caller goes one by one
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
-- Action 42230 = render with the most recent settings, auto-close the dialog. Synchronous: it
|
|
333
|
+
-- blocks the defer loop until the render is done
|
|
334
|
+
reaper.Main_OnCommand(42230, 0)
|
|
335
|
+
for _, t in ipairs(group) do t.rendered = t.expect end
|
|
336
|
+
return true
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
local function playback_info(reaper, t)
|
|
340
|
+
local offset_ms, inverted = 0, false
|
|
341
|
+
if not t.master then
|
|
342
|
+
local flag = reaper.GetMediaTrackInfo_Value(t.track, "I_PLAY_OFFSET_FLAG")
|
|
343
|
+
if flag & 1 == 0 then
|
|
344
|
+
local v = reaper.GetMediaTrackInfo_Value(t.track, "D_PLAY_OFFSET")
|
|
345
|
+
local srate = reaper.GetSetProjectInfo(0, "PROJECT_SRATE", 0, false)
|
|
346
|
+
if not srate or srate <= 0 then srate = M.SAMPLE_RATE end
|
|
347
|
+
offset_ms = (flag & 2 ~= 0) and (v / srate * 1000) or (v * 1000)
|
|
348
|
+
end
|
|
349
|
+
inverted = reaper.GetMediaTrackInfo_Value(t.track, "B_PHASE") ~= 0
|
|
350
|
+
end
|
|
351
|
+
return offset_ms, inverted
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
--- Render (or reuse) the given tracks over a range.
|
|
355
|
+
--- params: trackIndices (0-based, -1 = master), startTime, endTime, commandId, fresh (skip the
|
|
356
|
+
--- cache), restoreFocus (false: leave focus where the render put it).
|
|
357
|
+
--- Returns { tracks = { per track, in order }, rendered = n, cached = n } or nil, error.
|
|
358
|
+
function M.render_tracks(reaper, params, e)
|
|
359
|
+
local env = mkenv(e)
|
|
360
|
+
local indices = params.trackIndices
|
|
361
|
+
if type(indices) ~= "table" or #indices == 0 then return nil, "Missing required param: trackIndices" end
|
|
362
|
+
if #indices > M.MAX_TRACKS then return nil, "Too many tracks: max " .. M.MAX_TRACKS .. " per call" end
|
|
363
|
+
if not (num(params.startTime) and num(params.endTime)) then return nil, "Missing required params: startTime, endTime" end
|
|
364
|
+
if params.endTime <= params.startTime then return nil, "endTime must be greater than startTime" end
|
|
365
|
+
|
|
366
|
+
-- Resolve everything before anything is touched
|
|
367
|
+
local seen, targets = {}, {}
|
|
368
|
+
for i, idx in ipairs(indices) do
|
|
369
|
+
if not (int(idx) and idx >= -1) then return nil, "Bad track index: " .. tostring(idx) end
|
|
370
|
+
if seen[idx] then return nil, "trackIndices must not repeat a track" end
|
|
371
|
+
seen[idx] = true
|
|
372
|
+
local master = idx == -1
|
|
373
|
+
local track = master and reaper.GetMasterTrack(0) or reaper.GetTrack(0, idx)
|
|
374
|
+
if not track then return nil, "Track " .. tostring(idx) .. " not found" end
|
|
375
|
+
targets[i] = { index = idx, track = track, master = master, name = track_name(reaper, track, idx, master) }
|
|
376
|
+
targets[i].key = string.format("%s|%.4f|%.4f", track_id(reaper, targets[i]), params.startTime, params.endTime)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
local stamp = stamp_of(reaper)
|
|
380
|
+
drop_stale(env, stamp)
|
|
381
|
+
local cached = 0
|
|
382
|
+
local missing = {}
|
|
383
|
+
for _, t in ipairs(targets) do
|
|
384
|
+
local hit = stamp and params.fresh ~= true and lookup(env, t.key)
|
|
385
|
+
if hit then
|
|
386
|
+
t.path, t.cached = hit.path, true
|
|
387
|
+
cached = cached + 1
|
|
388
|
+
else
|
|
389
|
+
missing[#missing + 1] = t
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
if #missing > 0 then
|
|
394
|
+
local ctx = {
|
|
395
|
+
id = tostring(params.commandId or env.now()):gsub("-", ""):sub(1, 16),
|
|
396
|
+
dir = temp_dir(reaper, env),
|
|
397
|
+
saved = capture(reaper),
|
|
398
|
+
}
|
|
399
|
+
local front = params.restoreFocus ~= false and M.front_app(reaper) or nil
|
|
400
|
+
|
|
401
|
+
local ok, err = pcall(function()
|
|
402
|
+
apply_common(reaper, ctx.dir, params.startTime, params.endTime)
|
|
403
|
+
-- The master is its own render. The tracks go together where they do not feed each other
|
|
404
|
+
local groups, loose = {}, {}
|
|
405
|
+
for _, t in ipairs(missing) do
|
|
406
|
+
if t.master then
|
|
407
|
+
groups[#groups + 1] = { t }
|
|
408
|
+
else
|
|
409
|
+
local placed = false
|
|
410
|
+
for _, g in ipairs(loose) do
|
|
411
|
+
local free = true
|
|
412
|
+
for _, o in ipairs(g) do if related(reaper, t.track, o.track) then free = false; break end end
|
|
413
|
+
if free then g[#g + 1] = t; placed = true; break end
|
|
414
|
+
end
|
|
415
|
+
if not placed then loose[#loose + 1] = { t } end
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
for _, g in ipairs(loose) do groups[#groups + 1] = g end
|
|
419
|
+
for _, g in ipairs(groups) do
|
|
420
|
+
if not render_group(reaper, env, ctx, g) then
|
|
421
|
+
-- The stems' files could not be told apart: one render per track instead
|
|
422
|
+
for _, t in ipairs(g) do render_group(reaper, env, ctx, { t }) end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end)
|
|
426
|
+
|
|
427
|
+
local restored, rerr = pcall(restore, reaper, ctx.saved)
|
|
428
|
+
if not restored then
|
|
429
|
+
reaper.ShowConsoleMsg("[reaper-mcp] Warning: failed to restore render settings: " .. tostring(rerr) .. "\n")
|
|
430
|
+
end
|
|
431
|
+
M.restore_front(reaper, front)
|
|
432
|
+
if not ok then return nil, "Render failed: " .. tostring(err) end
|
|
433
|
+
|
|
434
|
+
-- Our own settings writes may have moved REAPER's counter: renders cached before this pass are
|
|
435
|
+
-- still good when nothing else changed since the lookup, so they follow the counter
|
|
436
|
+
local after = stamp_of(reaper)
|
|
437
|
+
if stamp and after and after ~= stamp then
|
|
438
|
+
for _, c in ipairs(cache) do if c.stamp == stamp then c.stamp = after end end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
for _, t in ipairs(missing) do
|
|
442
|
+
local path = t.rendered
|
|
443
|
+
local info = path and file_info(env, path)
|
|
444
|
+
if not info then
|
|
445
|
+
return nil, "Render produced no output file at: " .. tostring(path or t.expect)
|
|
446
|
+
end
|
|
447
|
+
-- RENDER_FORMAT was set to "evaw"; if REAPER ignored it the file could be any codec the user last rendered.
|
|
448
|
+
-- Fail here rather than in the sidecar with a confusing "Cannot read audio file"
|
|
449
|
+
if not info.wav then
|
|
450
|
+
remove_file(env, path)
|
|
451
|
+
return nil, "Render produced a non-WAV file (RENDER_FORMAT may not have been applied). " ..
|
|
452
|
+
"First 4 bytes: " .. (info.magic and string.format("%q", info.magic) or "<empty>")
|
|
453
|
+
end
|
|
454
|
+
t.path = path
|
|
455
|
+
if after then
|
|
456
|
+
store(env, { key = t.key, path = path, bytes = info.bytes, at = env.now(), stamp = after })
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
local out = {}
|
|
462
|
+
for i, t in ipairs(targets) do
|
|
463
|
+
local offset_ms, inverted = playback_info(reaper, t)
|
|
464
|
+
out[i] = {
|
|
465
|
+
trackIndex = t.index,
|
|
466
|
+
trackName = t.name,
|
|
467
|
+
wavPath = t.path,
|
|
468
|
+
durationSeconds = params.endTime - params.startTime,
|
|
469
|
+
sampleRate = M.SAMPLE_RATE,
|
|
470
|
+
channelCount = 2,
|
|
471
|
+
playOffsetMs = offset_ms,
|
|
472
|
+
phaseInverted = inverted,
|
|
473
|
+
cached = t.cached == true,
|
|
474
|
+
-- The cache deletes its files itself: callers must leave them alone
|
|
475
|
+
cacheOwned = true,
|
|
476
|
+
}
|
|
477
|
+
end
|
|
478
|
+
return { tracks = out, rendered = #missing, cached = cached }
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
--- One track: the shape render_track_to_wav has always returned
|
|
482
|
+
function M.render_track(reaper, params, e)
|
|
483
|
+
if params.trackIndex == nil then return nil, "Missing required param: trackIndex" end
|
|
484
|
+
local p = {}
|
|
485
|
+
for k, v in pairs(params) do p[k] = v end
|
|
486
|
+
p.trackIndices = { params.trackIndex }
|
|
487
|
+
local res, err = M.render_tracks(reaper, p, e)
|
|
488
|
+
if not res then return nil, err end
|
|
489
|
+
return res.tracks[1]
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
return M
|