@mthines/reaper-mcp 0.19.0 → 0.70.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +19 -570
  2. package/bridge-ui/mcp_bridge.html +129 -0
  3. package/bridge-ui/mcp_bridge.webmanifest +16 -0
  4. package/bridge-ui/mcp_bridge_icon_192.png +0 -0
  5. package/bridge-ui/mcp_bridge_icon_512.png +0 -0
  6. package/bridge-ui/mcp_bridge_sw.js +35 -0
  7. package/claude/agents/plugin-researcher.md +188 -0
  8. package/claude/knowledge/reference/aesthetics-scoring.md +48 -0
  9. package/{knowledge → claude/knowledge}/reference/common-mistakes.md +1 -1
  10. package/claude/knowledge/reference/lorekit-setup.md +36 -0
  11. package/claude/knowledge/reference/memory-protocol.md +377 -0
  12. package/{knowledge → claude/knowledge}/reference/metering.md +5 -5
  13. package/claude/knowledge/reference/perceived-loudness.md +182 -0
  14. package/{knowledge → claude/knowledge}/workflows/editing.md +4 -0
  15. package/{knowledge → claude/knowledge}/workflows/gain-staging.md +21 -23
  16. package/{knowledge → claude/knowledge}/workflows/learn-plugin.md +15 -48
  17. package/{knowledge → claude/knowledge}/workflows/master-bus.md +13 -1
  18. package/claude/knowledge/workflows/section-levels.md +138 -0
  19. package/{knowledge → claude/knowledge}/workflows/session-prep.md +4 -0
  20. package/claude/knowledge/workflows/transient-lighting.md +52 -0
  21. package/claude/skills/critique/SKILL.md +131 -0
  22. package/claude/skills/editing/SKILL.md +281 -0
  23. package/claude/skills/learn-plugin/SKILL.md +210 -0
  24. package/claude/skills/mastering/SKILL.md +151 -0
  25. package/claude/skills/mix-memory/SKILL.md +140 -0
  26. package/claude/skills/mixer/SKILL.md +253 -0
  27. package/claude/skills/new-extension/SKILL.md +324 -0
  28. package/main.js +7843 -1940
  29. package/package.json +17 -15
  30. package/reaper/CLAUDE.md +22 -4
  31. package/reaper/mcp_actions_lib.lua +375 -0
  32. package/reaper/mcp_analyzer.jsfx +27 -12
  33. package/reaper/mcp_arrange_lib.lua +949 -0
  34. package/reaper/mcp_bridge.lua +2321 -758
  35. package/reaper/mcp_edit_views_lib.lua +1378 -0
  36. package/reaper/mcp_envelopes_lib.lua +748 -0
  37. package/reaper/mcp_extensions_lib.lua +55 -0
  38. package/reaper/mcp_fxstate_lib.lua +144 -0
  39. package/reaper/mcp_gain_trace.jsfx +126 -0
  40. package/reaper/mcp_items_lib.lua +877 -0
  41. package/reaper/mcp_lufs_meter.jsfx +16 -13
  42. package/reaper/mcp_midi_lib.lua +555 -0
  43. package/reaper/mcp_mixing_lib.lua +1021 -0
  44. package/reaper/mcp_plan_lib.lua +801 -0
  45. package/reaper/mcp_prefs_lib.lua +127 -0
  46. package/reaper/mcp_project_lib.lua +1088 -0
  47. package/reaper/mcp_recording_lib.lua +595 -0
  48. package/reaper/mcp_render_lib.lua +492 -0
  49. package/reaper/mcp_snapshot_lib.lua +547 -120
  50. package/reaper/mcp_snapshot_next.lua +10 -4
  51. package/reaper/mcp_snapshot_prev.lua +11 -4
  52. package/reaper/mcp_snapshot_quick_save.lua +7 -32
  53. package/reaper/mcp_snapshots_ui_lib.lua +258 -0
  54. package/reaper/mcp_tracks_lib.lua +409 -0
  55. package/sidecar/analysis.py +661 -0
  56. package/sidecar/chords.py +96 -0
  57. package/sidecar/mixcheck.py +295 -0
  58. package/sidecar/requirements-chords.txt +6 -0
  59. package/sidecar/requirements-separation.txt +3 -0
  60. package/sidecar/requirements.txt +17 -0
  61. package/sidecar/separation.py +142 -0
  62. package/sidecar/server.py +622 -0
  63. package/sidecar/tests/test_analysis.py +249 -0
  64. package/sidecar/tests/test_chords.py +117 -0
  65. package/sidecar/tests/test_mixcheck.py +163 -0
  66. package/sidecar/tests/test_separation.py +73 -0
  67. package/sidecar/tests/test_server.py +464 -0
  68. package/sidecar/tests/test_verify.py +243 -0
  69. package/sidecar/tests/test_voice_render.py +268 -0
  70. package/sidecar/tests/test_voice_sketch.py +140 -0
  71. package/sidecar/verify.py +224 -0
  72. package/sidecar/voice_render.py +295 -0
  73. package/sidecar/voice_sketch.py +280 -0
  74. package/claude-agents/critique.md +0 -147
  75. package/claude-agents/editor.md +0 -184
  76. package/claude-agents/levels.md +0 -107
  77. package/claude-agents/mastering.md +0 -129
  78. package/claude-agents/mixer.md +0 -248
  79. package/claude-agents/preflight.md +0 -149
  80. package/claude-agents/producer.md +0 -159
  81. package/claude-agents/setup.md +0 -150
  82. package/claude-agents/stems.md +0 -135
  83. package/claude-rules/architecture.md +0 -39
  84. package/claude-rules/development.md +0 -54
  85. package/claude-rules/lua-bridge.md +0 -50
  86. package/claude-rules/testing.md +0 -42
  87. package/claude-skills/learn-plugin.md +0 -123
  88. package/knowledge/CLAUDE.md +0 -71
  89. package/knowledge/plugins/_template.md +0 -82
  90. package/knowledge/plugins/fabfilter/pro-c-2.md +0 -117
  91. package/knowledge/plugins/fabfilter/pro-l-2.md +0 -95
  92. package/knowledge/plugins/fabfilter/pro-q-3.md +0 -112
  93. package/knowledge/plugins/neural-dsp/helix-native.md +0 -104
  94. package/knowledge/plugins/stock-reaper/js-1175-compressor.md +0 -94
  95. package/knowledge/plugins/stock-reaper/rea-comp.md +0 -100
  96. package/knowledge/plugins/stock-reaper/rea-delay.md +0 -95
  97. package/knowledge/plugins/stock-reaper/rea-eq.md +0 -103
  98. package/knowledge/plugins/stock-reaper/rea-gate.md +0 -99
  99. package/knowledge/plugins/stock-reaper/rea-limit.md +0 -75
  100. package/knowledge/plugins/stock-reaper/rea-verb.md +0 -76
  101. package/knowledge/reference/perceived-loudness.md +0 -122
  102. package/reaper/mcp_snapshot_manager.lua +0 -1001
  103. /package/{knowledge → claude/knowledge}/genres/_template.md +0 -0
  104. /package/{knowledge → claude/knowledge}/genres/electronic.md +0 -0
  105. /package/{knowledge → claude/knowledge}/genres/hip-hop.md +0 -0
  106. /package/{knowledge → claude/knowledge}/genres/metal.md +0 -0
  107. /package/{knowledge → claude/knowledge}/genres/orchestral.md +0 -0
  108. /package/{knowledge → claude/knowledge}/genres/pop.md +0 -0
  109. /package/{knowledge → claude/knowledge}/genres/rock.md +0 -0
  110. /package/{knowledge → claude/knowledge}/reference/compression.md +0 -0
  111. /package/{knowledge → claude/knowledge}/reference/frequencies.md +0 -0
  112. /package/{knowledge → claude/knowledge}/workflows/delivery.md +0 -0
  113. /package/{knowledge → claude/knowledge}/workflows/drum-bus.md +0 -0
  114. /package/{knowledge → claude/knowledge}/workflows/low-end.md +0 -0
  115. /package/{knowledge → claude/knowledge}/workflows/stem-prep.md +0 -0
  116. /package/{knowledge → claude/knowledge}/workflows/vocal-chain.md +0 -0
@@ -0,0 +1,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