@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,1088 @@
1
+ -- mcp_project_lib.lua — project operations, tempo map and marker editing.
2
+ --
3
+ -- Pure logic: every function takes the ReaScript API as `api` (and, where a
4
+ -- file is involved, an `fs` table: exists(path), dir_exists(path),
5
+ -- remove(path)), so it runs under a fake API in unit tests. Loaded by
6
+ -- mcp_bridge.lua with dofile.
7
+ --
8
+ -- Every function returns a result table, or nil and a reason. A refused
9
+ -- request is validated in full before the first write, so it changes nothing.
10
+ -- Nothing here opens a dialog: a save without a path on a never-saved
11
+ -- project, and a render over existing files, are refused instead.
12
+
13
+ local M = {}
14
+
15
+ local UNDO_ALL = -1
16
+
17
+ local function edit(api, label, fn)
18
+ api.Undo_BeginBlock()
19
+ local ok, err = pcall(fn)
20
+ api.Undo_EndBlock("MCP: " .. label, UNDO_ALL)
21
+ if not ok then error(err, 0) end
22
+ end
23
+
24
+ local function is_int(v) return type(v) == "number" and v == math.floor(v) end
25
+
26
+ local function project_path(api)
27
+ local _, path = api.EnumProjects(-1, "")
28
+ return path or ""
29
+ end
30
+
31
+ -- ---------------------------------------------------------------------------
32
+ -- Undo / redo
33
+ -- ---------------------------------------------------------------------------
34
+
35
+ local function undo_state(api)
36
+ return { nextUndo = api.Undo_CanUndo2(0), nextRedo = api.Undo_CanRedo2(0) }
37
+ end
38
+
39
+ --- undo_project: undo the last undo point and report its label.
40
+ function M.undo(api)
41
+ local label = api.Undo_CanUndo2(0)
42
+ if not label or label == "" then return nil, "nothing to undo" end
43
+ api.Undo_DoUndo2(0)
44
+ if api.UpdateArrange then api.UpdateArrange() end
45
+ if api.UpdateTimeline then api.UpdateTimeline() end
46
+ local out = undo_state(api)
47
+ out.undone = label
48
+ return out
49
+ end
50
+
51
+ --- redo_project: redo the last undone point and report its label.
52
+ function M.redo(api)
53
+ local label = api.Undo_CanRedo2(0)
54
+ if not label or label == "" then return nil, "nothing to redo" end
55
+ api.Undo_DoRedo2(0)
56
+ if api.UpdateArrange then api.UpdateArrange() end
57
+ if api.UpdateTimeline then api.UpdateTimeline() end
58
+ local out = undo_state(api)
59
+ out.redone = label
60
+ return out
61
+ end
62
+
63
+ -- ---------------------------------------------------------------------------
64
+ -- Save
65
+ -- ---------------------------------------------------------------------------
66
+
67
+ local function parent_dir(path)
68
+ return path:match("^(.*)[/\\][^/\\]*$")
69
+ end
70
+
71
+ --- save_project. Without a path, saves the project where it already lives; a
72
+ --- project that was never saved is refused (REAPER would ask where to save it
73
+ --- in a dialog, which blocks the bridge). With a path, writes the project to
74
+ --- that .rpp file; an existing different file is refused unless overwrite.
75
+ --- A path writes a copy (Main_SaveProjectEx, verified live): the open project
76
+ --- keeps its own path, so the result says savedAsCopy.
77
+ function M.save(api, params, fs)
78
+ local current = project_path(api)
79
+ local path = params.path
80
+ if path == nil or path == "" then
81
+ if current == "" then
82
+ return nil, "refused: the project was never saved, so REAPER would open a Save As dialog — pass path (an absolute .rpp path) to save it"
83
+ end
84
+ api.Main_SaveProject(0, false)
85
+ if not fs.exists(current) then return nil, "save failed: " .. current .. " does not exist after saving" end
86
+ return { saved = true, path = current }
87
+ end
88
+
89
+ if type(path) ~= "string" or not path:lower():match("%.rpp$") then
90
+ return nil, "path must end in .rpp"
91
+ end
92
+ if not (path:sub(1, 1) == "/" or path:match("^%a:[/\\]")) then
93
+ return nil, "path must be absolute"
94
+ end
95
+ local dir = parent_dir(path)
96
+ if not dir or not fs.dir_exists(dir) then
97
+ return nil, "directory " .. tostring(dir) .. " does not exist"
98
+ end
99
+ if path ~= current and fs.exists(path) and params.overwrite ~= true then
100
+ return nil, "refused: " .. path .. " already exists — pass overwrite: true to replace it"
101
+ end
102
+ api.Main_SaveProjectEx(0, path, 0)
103
+ if not fs.exists(path) then return nil, "save failed: " .. path .. " does not exist after saving" end
104
+ local now = project_path(api)
105
+ return { saved = true, path = path, projectPath = now, savedAsCopy = now ~= path }
106
+ end
107
+
108
+ -- ---------------------------------------------------------------------------
109
+ -- Project settings + transport options
110
+ -- ---------------------------------------------------------------------------
111
+
112
+ M.METRONOME = 40364 -- Options: Toggle metronome (global, not per project)
113
+
114
+ --- get_project_settings
115
+ function M.get_settings(api)
116
+ local _, title = api.GetSetProjectInfo_String(0, "PROJECT_TITLE", "", false)
117
+ local _, author = api.GetSetProjectInfo_String(0, "PROJECT_AUTHOR", "", false)
118
+ return {
119
+ path = project_path(api),
120
+ sampleRate = math.floor(api.GetSetProjectInfo(0, "PROJECT_SRATE", 0, false)),
121
+ useProjectSampleRate = api.GetSetProjectInfo(0, "PROJECT_SRATE_USE", 0, false) == 1,
122
+ title = title or "",
123
+ author = author or "",
124
+ notes = api.GetSetProjectNotes(0, false, ""),
125
+ ["repeat"] = api.GetSetRepeatEx(0, -1) == 1,
126
+ metronome = api.GetToggleCommandState(M.METRONOME) == 1,
127
+ }
128
+ end
129
+
130
+ --- set_project_settings: any subset of get_settings' fields (minus path).
131
+ function M.set_settings(api, p)
132
+ if p.sampleRate ~= nil and not (is_int(p.sampleRate) and p.sampleRate >= 8000 and p.sampleRate <= 768000) then
133
+ return nil, "sampleRate must be an integer 8000-768000 — nothing changed"
134
+ end
135
+ for _, k in ipairs({ "title", "author", "notes" }) do
136
+ if p[k] ~= nil and type(p[k]) ~= "string" then return nil, k .. " must be a string — nothing changed" end
137
+ end
138
+ for _, k in ipairs({ "useProjectSampleRate", "repeat", "metronome" }) do
139
+ if p[k] ~= nil and type(p[k]) ~= "boolean" then return nil, k .. " must be true or false — nothing changed" end
140
+ end
141
+
142
+ local any = false
143
+ for _, k in ipairs({ "sampleRate", "useProjectSampleRate", "title", "author", "notes" }) do
144
+ if p[k] ~= nil then any = true end
145
+ end
146
+ if any then edit(api, "Set project settings", function()
147
+ if p.sampleRate ~= nil then api.GetSetProjectInfo(0, "PROJECT_SRATE", p.sampleRate, true) end
148
+ if p.useProjectSampleRate ~= nil then api.GetSetProjectInfo(0, "PROJECT_SRATE_USE", p.useProjectSampleRate and 1 or 0, true) end
149
+ if p.title ~= nil then api.GetSetProjectInfo_String(0, "PROJECT_TITLE", p.title, true) end
150
+ if p.author ~= nil then api.GetSetProjectInfo_String(0, "PROJECT_AUTHOR", p.author, true) end
151
+ if p.notes ~= nil then api.GetSetProjectNotes(0, true, p.notes) end
152
+ end) end
153
+ -- Transport options are not undoable in REAPER; set outside the undo block.
154
+ if p["repeat"] ~= nil then api.GetSetRepeatEx(0, p["repeat"] and 1 or 0) end
155
+ if p.metronome ~= nil and (api.GetToggleCommandState(M.METRONOME) == 1) ~= p.metronome then
156
+ api.Main_OnCommand(M.METRONOME, 0)
157
+ end
158
+ return M.get_settings(api)
159
+ end
160
+
161
+ -- ---------------------------------------------------------------------------
162
+ -- Tempo map
163
+ -- ---------------------------------------------------------------------------
164
+
165
+ --- Same shape as the bridge's get_tempo_map.
166
+ function M.tempo_map(api)
167
+ local count = api.CountTempoTimeSigMarkers(0)
168
+ local markers = {}
169
+ for i = 0, count - 1 do
170
+ local _, timepos, _, _, bpm, num, den, linear = api.GetTempoTimeSigMarker(0, i)
171
+ markers[#markers + 1] = {
172
+ index = i, position = timepos, bpm = bpm,
173
+ timeSigNumerator = num, timeSigDenominator = den, linearTempo = linear,
174
+ }
175
+ end
176
+ if count == 0 then
177
+ local num, den = api.TimeMap_GetTimeSigAtTime(0, 0)
178
+ markers[1] = { index = 0, position = 0, bpm = api.Master_GetTempo(), timeSigNumerator = num, timeSigDenominator = den, linearTempo = false }
179
+ end
180
+ return { markers = markers, count = #markers, tempoMarkerCount = count }
181
+ end
182
+
183
+ local function check_tempo_fields(p)
184
+ if p.bpm ~= nil and (type(p.bpm) ~= "number" or p.bpm <= 0 or p.bpm > 960) then
185
+ return "bpm must be > 0 and <= 960 — nothing changed"
186
+ end
187
+ if p.position ~= nil and (type(p.position) ~= "number" or p.position < 0) then
188
+ return "position must be >= 0 seconds — nothing changed"
189
+ end
190
+ if p.timeSigNumerator ~= nil and not (is_int(p.timeSigNumerator) and p.timeSigNumerator >= 1 and p.timeSigNumerator <= 255) then
191
+ return "timeSigNumerator must be 1-255 — nothing changed"
192
+ end
193
+ if p.timeSigDenominator ~= nil then
194
+ local d = p.timeSigDenominator
195
+ local ok = false
196
+ for _, v in ipairs({ 1, 2, 4, 8, 16, 32, 64 }) do if d == v then ok = true end end
197
+ if not ok then return "timeSigDenominator must be 1, 2, 4, 8, 16, 32 or 64 — nothing changed" end
198
+ end
199
+ if (p.timeSigNumerator == nil) ~= (p.timeSigDenominator == nil) then
200
+ return "give timeSigNumerator and timeSigDenominator together — nothing changed"
201
+ end
202
+ return nil
203
+ end
204
+
205
+ local function tempo_count(api) return api.CountTempoTimeSigMarkers(0) end
206
+
207
+ local function check_tempo_index(api, idx)
208
+ if not is_int(idx) or idx < 0 or idx >= tempo_count(api) then
209
+ return "no tempo marker " .. tostring(idx) .. " (the project has " .. tempo_count(api) .. ") — nothing changed"
210
+ end
211
+ return nil
212
+ end
213
+
214
+ local function done_tempo(api)
215
+ if api.UpdateTimeline then api.UpdateTimeline() end
216
+ return M.tempo_map(api)
217
+ end
218
+
219
+ --- add_tempo_marker: { position (s), bpm, timeSigNumerator?, timeSigDenominator?, linear? }
220
+ function M.add_tempo_marker(api, p)
221
+ if p.position == nil or p.bpm == nil then return nil, "position and bpm required" end
222
+ local err = check_tempo_fields(p)
223
+ if err then return nil, err end
224
+ local before = tempo_count(api)
225
+ edit(api, "Add tempo marker", function()
226
+ api.SetTempoTimeSigMarker(0, -1, p.position, -1, -1, p.bpm,
227
+ p.timeSigNumerator or 0, p.timeSigDenominator or 0, p.linear == true)
228
+ end)
229
+ local out = done_tempo(api)
230
+ -- REAPER replaces a marker already at that position instead of adding one.
231
+ if out.tempoMarkerCount == before then out.replacedExisting = true end
232
+ return out
233
+ end
234
+
235
+ --- edit_tempo_marker: { index, position?, bpm?, timeSigNumerator?, timeSigDenominator?, linear? }
236
+ function M.edit_tempo_marker(api, p)
237
+ local err = check_tempo_index(api, p.index) or check_tempo_fields(p)
238
+ if err then return nil, err end
239
+ local _, timepos, _, _, bpm, num, den, linear = api.GetTempoTimeSigMarker(0, p.index)
240
+ local lin = linear
241
+ if p.linear ~= nil then lin = p.linear == true end
242
+ edit(api, "Edit tempo marker", function()
243
+ api.SetTempoTimeSigMarker(0, p.index, p.position or timepos, -1, -1, p.bpm or bpm,
244
+ p.timeSigNumerator or (num > 0 and num or 0), p.timeSigDenominator or (den > 0 and den or 0), lin)
245
+ end)
246
+ return done_tempo(api)
247
+ end
248
+
249
+ --- delete_tempo_marker: { index }
250
+ function M.delete_tempo_marker(api, p)
251
+ local err = check_tempo_index(api, p.index)
252
+ if err then return nil, err end
253
+ local before = tempo_count(api)
254
+ edit(api, "Delete tempo marker", function() api.DeleteTempoTimeSigMarker(0, p.index) end)
255
+ if tempo_count(api) == before then
256
+ -- Seen live (REAPER 7.40): the start marker carrying the time signature
257
+ -- reports success but stays. Say so rather than claim a deletion.
258
+ return nil, "REAPER did not delete tempo marker " .. p.index .. " (the marker at the project start cannot be removed this way) — nothing changed; use edit_tempo_marker or set_project_tempo to change it"
259
+ end
260
+ return done_tempo(api)
261
+ end
262
+
263
+ --- set_project_tempo: { bpm }. Sets the tempo at the project start: with no
264
+ --- tempo markers the project BPM; with a marker at 0 that marker's BPM;
265
+ --- otherwise a marker is added at 0 (REAPER's SetCurrentBPM would add one at
266
+ --- the edit cursor instead, so it is only used with no markers).
267
+ function M.set_tempo(api, p)
268
+ if p.bpm == nil then return nil, "bpm required" end
269
+ local err = check_tempo_fields({ bpm = p.bpm })
270
+ if err then return nil, err end
271
+ if tempo_count(api) == 0 then
272
+ edit(api, "Set project tempo", function() api.SetCurrentBPM(0, p.bpm, false) end)
273
+ else
274
+ local _, timepos, _, _, _, num, den, linear = api.GetTempoTimeSigMarker(0, 0)
275
+ edit(api, "Set project tempo", function()
276
+ if timepos <= 1e-9 then
277
+ api.SetTempoTimeSigMarker(0, 0, timepos, -1, -1, p.bpm, num > 0 and num or 0, den > 0 and den or 0, linear)
278
+ else
279
+ api.SetTempoTimeSigMarker(0, -1, 0, -1, -1, p.bpm, 0, 0, false)
280
+ end
281
+ end)
282
+ end
283
+ return done_tempo(api)
284
+ end
285
+
286
+ -- ---------------------------------------------------------------------------
287
+ -- Markers and regions
288
+ -- ---------------------------------------------------------------------------
289
+
290
+ local function find_marker(api, isrgn, number)
291
+ local _, nm, nr = api.CountProjectMarkers(0)
292
+ for i = 0, nm + nr - 1 do
293
+ local _, rgn, pos, rend, name, idx, color = api.EnumProjectMarkers3(0, i)
294
+ if rgn == isrgn and idx == number then
295
+ return { pos = pos, rend = rend, name = name, color = color }
296
+ end
297
+ end
298
+ return nil
299
+ end
300
+
301
+ local function native_color(c)
302
+ if c == 0 then return 0 end
303
+ return c | 0x1000000
304
+ end
305
+
306
+ local function edit_marker_or_region(api, p, isrgn)
307
+ local key = isrgn and "regionIndex" or "markerIndex"
308
+ local kind = isrgn and "region" or "marker"
309
+ local number = p[key]
310
+ if not is_int(number) then return nil, key .. " required" end
311
+ local m = find_marker(api, isrgn, number)
312
+ if not m then return nil, "no " .. kind .. " " .. number .. " — nothing changed" end
313
+ if p.name ~= nil and type(p.name) ~= "string" then return nil, "name must be a string — nothing changed" end
314
+ if p.color ~= nil and not (is_int(p.color) and p.color >= 0) then return nil, "color must be a native color int >= 0 — nothing changed" end
315
+
316
+ local pos, rend = m.pos, m.rend
317
+ if isrgn then
318
+ pos, rend = p.start or pos, p["end"] or rend
319
+ if type(pos) ~= "number" or pos < 0 or type(rend) ~= "number" or rend <= pos then
320
+ return nil, "region end must be after start (both >= 0) — nothing changed"
321
+ end
322
+ else
323
+ pos = p.position or pos
324
+ if type(pos) ~= "number" or pos < 0 then return nil, "position must be >= 0 — nothing changed" end
325
+ end
326
+ local name = p.name or m.name
327
+ -- color 0 means "leave unchanged" to SetProjectMarker4; keep the current one.
328
+ local color = p.color ~= nil and native_color(p.color) or m.color
329
+ local flags = (name == "") and 1 or 0 -- &1: clear the name
330
+
331
+ edit(api, "Edit " .. kind, function()
332
+ api.SetProjectMarker4(0, number, isrgn, pos, rend, name, color, flags)
333
+ end)
334
+ if api.UpdateArrange then api.UpdateArrange() end
335
+ local now = find_marker(api, isrgn, number)
336
+ if isrgn then
337
+ return { index = number, name = now.name, start = now.pos, ["end"] = now.rend, color = now.color }
338
+ end
339
+ return { index = number, name = now.name, position = now.pos, color = now.color }
340
+ end
341
+
342
+ --- edit_marker: { markerIndex, name?, position?, color? }
343
+ function M.edit_marker(api, p) return edit_marker_or_region(api, p, false) end
344
+
345
+ --- edit_region: { regionIndex, name?, start?, end?, color? }
346
+ function M.edit_region(api, p) return edit_marker_or_region(api, p, true) end
347
+
348
+ -- ---------------------------------------------------------------------------
349
+ -- Render settings + render
350
+ -- ---------------------------------------------------------------------------
351
+ --
352
+ -- Encodings below were read off REAPER 7.40 (render probes, 2026-10-30):
353
+ -- RENDER_FORMAT is base64 of fourcc + config. wav: evaw + int16 bits (16, 24,
354
+ -- 0x21 = 32-bit int, 0x20 = 32-bit float) + flags; aiff: ffia + int16 bits;
355
+ -- flac: calf + int32 bits + int32 compression; mp3: l3pm + int32 kbps, with
356
+ -- the same kbps again at byte 24.
357
+ -- RENDER_SETTINGS source bits: 0 master, 1 master + stems, 3 stems of the
358
+ -- selected tracks (2 lists no target in REAPER 7), 8 region render matrix
359
+ -- (needs region bounds), 32/64 selected items (via master), 128 selected
360
+ -- tracks via master; 512 embeds metadata.
361
+ -- RENDER_NORMALIZE: &1 on, &14 mode, &64 limit, &128 limit on true peak,
362
+ -- &256 only normalize files that are too loud; targets are linear amplitude.
363
+ -- RENDER_DITHER: &1/&2 dither / noise shaping on the master, &4/&8 on stems.
364
+ -- CalculateNormalization's target is in dB: with 0 the gain is the level.
365
+
366
+ -- Sink fourccs as REAPER stores them (byte-reversed).
367
+ M.FORMATS = { wav = "evaw", aiff = "ffia", flac = "calf", mp3 = "l3pm", ogg = "vggo", opus = "SggO" }
368
+ M.BOUNDS = { custom = 0, project = 1, time_selection = 2, all_regions = 3, selected_items = 4, selected_regions = 5 }
369
+ M.SOURCES = {
370
+ master = 0, master_and_stems = 1, stems = 3, region_matrix = 8,
371
+ selected_items = 32, selected_items_via_master = 64, selected_via_master = 128,
372
+ }
373
+ local SOURCE_MASK = 1 | 2 | 8 | 32 | 64 | 128
374
+ local EMBED_METADATA = 512
375
+ M.NORMALIZE = { lufs_i = 0, rms = 2, peak = 4, true_peak = 6, lufs_m_max = 8, lufs_s_max = 10 }
376
+ local NORM_ON, NORM_MODE, NORM_LIMIT, NORM_LIMIT_TP, NORM_TOO_LOUD = 1, 14, 64, 128, 256
377
+ -- Bit depth names per format → the code REAPER stores
378
+ local BIT_DEPTHS = {
379
+ wav = { ["16"] = 16, ["24"] = 24, ["32"] = 0x21, ["32f"] = 0x20 },
380
+ aiff = { ["16"] = 16, ["24"] = 24 },
381
+ flac = { ["16"] = 16, ["24"] = 24 },
382
+ }
383
+ local MP3_BITRATES = { 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320 }
384
+ -- Friendly metadata fields → the RENDER_METADATA keys each one writes (WAV INFO/BWF/AXML, MP3 ID3, OGG/FLAC Vorbis)
385
+ M.METADATA = {
386
+ title = { "ID3:TIT2", "INFO:INAM", "VORBIS:TITLE" },
387
+ artist = { "ID3:TPE1", "INFO:IART", "VORBIS:ARTIST" },
388
+ album = { "ID3:TALB", "INFO:IPRD", "VORBIS:ALBUM" },
389
+ genre = { "ID3:TCON", "INFO:IGNR", "VORBIS:GENRE" },
390
+ year = { "ID3:TYER", "INFO:ICRD", "VORBIS:DATE" },
391
+ isrc = { "ID3:TSRC", "AXML:ISRC", "VORBIS:ISRC" },
392
+ comment = { "ID3:COMM", "INFO:ICMT", "VORBIS:COMMENT" },
393
+ description = { "BWF:Description" },
394
+ }
395
+ local MAX_ANALYZED = 16
396
+ local MAX_METADATA_KEYS = 64
397
+
398
+ local B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
399
+
400
+ local function b64decode(s)
401
+ s = s:gsub("[^%w%+/=]", "")
402
+ local out, bits, n = {}, 0, 0
403
+ for i = 1, #s do
404
+ local c = s:sub(i, i)
405
+ if c == "=" then break end
406
+ bits = (bits << 6) | (B64:find(c, 1, true) - 1)
407
+ n = n + 6
408
+ if n >= 8 then
409
+ n = n - 8
410
+ out[#out + 1] = string.char((bits >> n) & 0xFF)
411
+ end
412
+ end
413
+ return table.concat(out)
414
+ end
415
+
416
+ local function b64encode(s)
417
+ local out = {}
418
+ for i = 1, #s, 3 do
419
+ local a, b, c = s:byte(i, i + 2)
420
+ local n = (a << 16) | ((b or 0) << 8) | (c or 0)
421
+ for k = 3, 0, -1 do
422
+ local idx = (n >> (k * 6)) & 63
423
+ out[#out + 1] = B64:sub(idx + 1, idx + 1)
424
+ end
425
+ if not b then out[#out - 1] = "="; out[#out] = "=" elseif not c then out[#out] = "=" end
426
+ end
427
+ return table.concat(out)
428
+ end
429
+
430
+ -- The stored format as { name, raw config bytes } (name "other:<fourcc>" for sinks outside the list)
431
+ local function format_of(stored)
432
+ if not stored or stored == "" then return nil end
433
+ for name, cc in pairs(M.FORMATS) do
434
+ if stored == cc then return name, cc end
435
+ end
436
+ local raw = b64decode(stored)
437
+ local cc = raw:sub(1, 4)
438
+ for name, want in pairs(M.FORMATS) do
439
+ if cc == want then return name, raw end
440
+ end
441
+ return "other:" .. cc, raw
442
+ end
443
+
444
+ local function format_name(stored) return (format_of(stored)) end
445
+
446
+ -- Bit depth / bitrate as configured in the stored blob (nil when REAPER keeps its defaults)
447
+ local function format_options(name, raw)
448
+ if not raw or #raw < 6 then return {} end
449
+ if name == "wav" or name == "aiff" or name == "flac" then
450
+ local code = name == "flac" and #raw >= 8 and string.unpack("<i4", raw, 5) or string.unpack("<I2", raw, 5)
451
+ for label, c in pairs(BIT_DEPTHS[name]) do
452
+ if c == code then return { bitDepth = label } end
453
+ end
454
+ return { bitDepth = tostring(code) }
455
+ elseif name == "mp3" and #raw >= 8 then
456
+ return { mp3Bitrate = string.unpack("<i4", raw, 5) }
457
+ end
458
+ return {}
459
+ end
460
+
461
+ -- The new blob for format `name` with a bit depth or bitrate; `raw` is the current config when it is the same format
462
+ local function build_format(name, raw, bit_depth, kbps)
463
+ if name == "flac" then
464
+ local level = raw and #raw >= 12 and string.unpack("<i4", raw, 9) or 5
465
+ return b64encode("calf" .. string.pack("<i4i4", BIT_DEPTHS.flac[bit_depth], level) .. (raw and raw:sub(13) or ""))
466
+ elseif name == "wav" or name == "aiff" then
467
+ local rest = raw and #raw >= 6 and raw:sub(7) or (name == "wav" and "\0" or "")
468
+ return b64encode(M.FORMATS[name] .. string.pack("<I2", BIT_DEPTHS[name][bit_depth]) .. rest)
469
+ elseif name == "mp3" then
470
+ if raw and #raw >= 28 then
471
+ return b64encode(raw:sub(1, 4) .. string.pack("<i4", kbps) .. raw:sub(9, 24) .. string.pack("<i4", kbps) .. raw:sub(29))
472
+ end
473
+ return b64encode("l3pm" .. string.pack("<i4i4i4i4i4i4i4", kbps, 0, 5, -1, 4, kbps, 0))
474
+ end
475
+ end
476
+
477
+ local function name_of(map, value)
478
+ for name, v in pairs(map) do
479
+ if v == value then return name end
480
+ end
481
+ return tostring(value)
482
+ end
483
+
484
+ local function source_name(settings)
485
+ local s = math.floor(settings) & SOURCE_MASK
486
+ if s & 8 ~= 0 then return "region_matrix" end
487
+ if s & 128 ~= 0 then return "selected_via_master" end
488
+ if s & 64 ~= 0 then return "selected_items_via_master" end
489
+ if s & 32 ~= 0 then return "selected_items" end
490
+ if s & 2 ~= 0 then return "stems" end
491
+ if s & 1 ~= 0 then return "master_and_stems" end
492
+ return "master"
493
+ end
494
+
495
+ local function to_db(lin)
496
+ if type(lin) ~= "number" or lin <= 0 then return nil end
497
+ return math.floor(20 * math.log(lin, 10) * 100 + 0.5) / 100
498
+ end
499
+
500
+ local function render_targets(api)
501
+ local _, s = api.GetSetProjectInfo_String(0, "RENDER_TARGETS", "", false)
502
+ local out = {}
503
+ for t in (s or ""):gmatch("[^;]+") do out[#out + 1] = t end
504
+ return out
505
+ end
506
+
507
+ local function metadata_keys(api)
508
+ local _, list = api.GetSetProjectInfo_String(0, "RENDER_METADATA", "", false)
509
+ local keys = {}
510
+ for k in (list or ""):gmatch("[^;]+") do
511
+ if #keys >= MAX_METADATA_KEYS then break end
512
+ keys[#keys + 1] = k
513
+ end
514
+ return keys
515
+ end
516
+
517
+ local function read_metadata(api)
518
+ local out = {}
519
+ for _, k in ipairs(metadata_keys(api)) do
520
+ local _, v = api.GetSetProjectInfo_String(0, "RENDER_METADATA", k, false)
521
+ out[k] = v or ""
522
+ end
523
+ return out
524
+ end
525
+
526
+ -- Region render matrix rows: regions that render at least one track
527
+ local function region_matrix(api)
528
+ local rows = {}
529
+ if not api.EnumRegionRenderMatrix then return rows end
530
+ local _, nm, nr = api.CountProjectMarkers(0)
531
+ local master = api.GetMasterTrack(0)
532
+ for i = 0, nm + nr - 1 do
533
+ local _, rgn, _, _, name, idx = api.EnumProjectMarkers3(0, i)
534
+ if rgn then
535
+ local tracks = {}
536
+ local j = 0
537
+ while true do
538
+ local tr = api.EnumRegionRenderMatrix(0, idx, j)
539
+ if not tr then break end
540
+ if tr == master then tracks[#tracks + 1] = "master"
541
+ else local _, n = api.GetTrackName(tr); tracks[#tracks + 1] = n or "" end
542
+ j = j + 1
543
+ end
544
+ if #tracks > 0 then rows[#rows + 1] = { regionIndex = idx, name = name, tracks = tracks } end
545
+ end
546
+ end
547
+ return rows
548
+ end
549
+
550
+ --- get_render_settings
551
+ function M.get_render(api)
552
+ local _, dir = api.GetSetProjectInfo_String(0, "RENDER_FILE", "", false)
553
+ local _, pattern = api.GetSetProjectInfo_String(0, "RENDER_PATTERN", "", false)
554
+ local _, fmt = api.GetSetProjectInfo_String(0, "RENDER_FORMAT", "", false)
555
+ local _, fmt2 = api.GetSetProjectInfo_String(0, "RENDER_FORMAT2", "", false)
556
+ local name, raw = format_of(fmt)
557
+ local settings = api.GetSetProjectInfo(0, "RENDER_SETTINGS", 0, false)
558
+ local norm = math.floor(api.GetSetProjectInfo(0, "RENDER_NORMALIZE", 0, false))
559
+ local dither = math.floor(api.GetSetProjectInfo(0, "RENDER_DITHER", 0, false))
560
+ local out = {
561
+ outputDirectory = dir or "",
562
+ filePattern = pattern or "",
563
+ format = name,
564
+ secondaryFormat = format_name(fmt2) or "none",
565
+ source = source_name(settings),
566
+ bounds = name_of(M.BOUNDS, math.floor(api.GetSetProjectInfo(0, "RENDER_BOUNDSFLAG", 0, false))),
567
+ startTime = api.GetSetProjectInfo(0, "RENDER_STARTPOS", 0, false),
568
+ endTime = api.GetSetProjectInfo(0, "RENDER_ENDPOS", 0, false),
569
+ sampleRate = math.floor(api.GetSetProjectInfo(0, "RENDER_SRATE", 0, false)),
570
+ channels = math.floor(api.GetSetProjectInfo(0, "RENDER_CHANNELS", 0, false)),
571
+ dither = dither & 1 ~= 0,
572
+ noiseShaping = dither & 2 ~= 0,
573
+ normalize = norm & NORM_ON ~= 0 and name_of(M.NORMALIZE, norm & NORM_MODE) or "off",
574
+ normalizeTarget = to_db(api.GetSetProjectInfo(0, "RENDER_NORMALIZE_TARGET", 0, false)),
575
+ normalizeOnlyTooLoud = norm & NORM_TOO_LOUD ~= 0,
576
+ limit = norm & NORM_LIMIT ~= 0,
577
+ limitCeiling = to_db(api.GetSetProjectInfo(0, "RENDER_BRICKWALL", 0, false)),
578
+ limitTruePeak = norm & NORM_LIMIT_TP ~= 0,
579
+ embedMetadata = math.floor(settings) & EMBED_METADATA ~= 0,
580
+ metadata = read_metadata(api),
581
+ targets = render_targets(api),
582
+ }
583
+ for k, v in pairs(format_options(name, raw)) do out[k] = v end
584
+ if out.source == "region_matrix" then out.regionMatrix = region_matrix(api) end
585
+ return out
586
+ end
587
+
588
+ local function bool_or_nil(p, keys)
589
+ for _, k in ipairs(keys) do
590
+ if p[k] ~= nil and type(p[k]) ~= "boolean" then return k .. " must be true or false — nothing changed" end
591
+ end
592
+ end
593
+
594
+ local function number_in(p, k, lo, hi, unit)
595
+ if p[k] ~= nil and not (type(p[k]) == "number" and p[k] >= lo and p[k] <= hi) then
596
+ return k .. " must be " .. lo .. " to " .. hi .. (unit or "") .. " — nothing changed"
597
+ end
598
+ end
599
+
600
+ -- Region index → its region number check, plus the tracks it should render (resolved)
601
+ local function resolve_matrix(api, rows, resolve)
602
+ if type(rows) ~= "table" then return nil, "regionMatrix must be a list of { regionIndex, tracks } — nothing changed" end
603
+ local out = {}
604
+ for i, row in ipairs(rows) do
605
+ if type(row) ~= "table" or not is_int(row.regionIndex) then
606
+ return nil, "regionMatrix[" .. i .. "].regionIndex required — nothing changed"
607
+ end
608
+ if not find_marker(api, true, row.regionIndex) then
609
+ return nil, "regionMatrix: no region " .. row.regionIndex .. " — nothing changed"
610
+ end
611
+ if type(row.tracks) ~= "table" then return nil, "regionMatrix[" .. i .. "].tracks must be a list — nothing changed" end
612
+ local tracks = {}
613
+ for _, ref in ipairs(row.tracks) do
614
+ local res, err = resolve(api, ref, {})
615
+ if not res then return nil, "regionMatrix: " .. err .. " — nothing changed" end
616
+ tracks[#tracks + 1] = res.track
617
+ end
618
+ out[#out + 1] = { region = row.regionIndex, tracks = tracks }
619
+ end
620
+ return out
621
+ end
622
+
623
+ --- set_render_settings: any subset of the fields get_render returns (minus
624
+ --- targets). sampleRate 0 = the project rate. `resolve` resolves track refs
625
+ --- (regionMatrix); a refused request changes nothing.
626
+ function M.set_render(api, p, resolve)
627
+ local cur = M.get_render(api)
628
+ local _, stored = api.GetSetProjectInfo_String(0, "RENDER_FORMAT", "", false)
629
+ local _, cur_raw = format_of(stored)
630
+ local fourcc
631
+ if p.format ~= nil then
632
+ fourcc = M.FORMATS[tostring(p.format):lower()]
633
+ if not fourcc then
634
+ local names = {}
635
+ for k in pairs(M.FORMATS) do names[#names + 1] = k end
636
+ table.sort(names)
637
+ return nil, "unsupported render format '" .. tostring(p.format) .. "' (use " .. table.concat(names, ", ") .. ") — nothing changed"
638
+ end
639
+ end
640
+ local fourcc2
641
+ if p.secondaryFormat ~= nil then
642
+ local f2 = tostring(p.secondaryFormat):lower()
643
+ fourcc2 = (f2 == "none") and "" or M.FORMATS[f2]
644
+ if not fourcc2 then
645
+ return nil, "unsupported secondary render format '" .. tostring(p.secondaryFormat) .. "' (use none or a format) — nothing changed"
646
+ end
647
+ end
648
+ if p.bounds ~= nil and M.BOUNDS[p.bounds] == nil then
649
+ return nil, "unknown bounds '" .. tostring(p.bounds) .. "' (use custom, project, time_selection, all_regions, selected_items or selected_regions) — nothing changed"
650
+ end
651
+ if p.source ~= nil and M.SOURCES[p.source] == nil then
652
+ return nil, "unknown source '" .. tostring(p.source) .. "' (use master, stems, master_and_stems, selected_via_master, region_matrix, selected_items or selected_items_via_master) — nothing changed"
653
+ end
654
+ local source = p.source or cur.source
655
+ local bounds = p.bounds or cur.bounds
656
+ if (p.source ~= nil or p.bounds ~= nil) and source == "region_matrix" and bounds ~= "all_regions" and bounds ~= "selected_regions" then
657
+ return nil, "source region_matrix renders regions: set bounds to all_regions or selected_regions — nothing changed"
658
+ end
659
+ if p.regionMatrix ~= nil and source ~= "region_matrix" then
660
+ return nil, "regionMatrix applies to source region_matrix — nothing changed"
661
+ end
662
+ if p.sampleRate ~= nil and not (is_int(p.sampleRate) and (p.sampleRate == 0 or (p.sampleRate >= 8000 and p.sampleRate <= 768000))) then
663
+ return nil, "sampleRate must be 0 (project rate) or 8000-768000 — nothing changed"
664
+ end
665
+ if p.channels ~= nil and not (is_int(p.channels) and p.channels >= 1 and p.channels <= 64) then
666
+ return nil, "channels must be 1-64 — nothing changed"
667
+ end
668
+ for _, k in ipairs({ "startTime", "endTime" }) do
669
+ if p[k] ~= nil and (type(p[k]) ~= "number" or p[k] < 0) then return nil, k .. " must be >= 0 — nothing changed" end
670
+ end
671
+ local s = p.startTime or cur.startTime
672
+ local e = p.endTime or cur.endTime
673
+ if (p.startTime ~= nil or p.endTime ~= nil) and e <= s then
674
+ return nil, "endTime must be after startTime — nothing changed"
675
+ end
676
+ for _, k in ipairs({ "outputDirectory", "filePattern" }) do
677
+ if p[k] ~= nil and type(p[k]) ~= "string" then return nil, k .. " must be a string — nothing changed" end
678
+ end
679
+ if p.filePattern == "" then return nil, "filePattern must not be empty — nothing changed" end
680
+
681
+ -- Format options apply to the primary format: the new one, else the current one
682
+ local fmt = p.format and tostring(p.format):lower() or cur.format
683
+ local blob
684
+ if p.bitDepth ~= nil then
685
+ local depths = BIT_DEPTHS[fmt]
686
+ if not depths then return nil, "bitDepth applies to wav, aiff and flac (format is " .. tostring(fmt) .. "; mp3 takes mp3Bitrate) — nothing changed" end
687
+ if not depths[tostring(p.bitDepth)] then
688
+ local names = {}
689
+ for k in pairs(depths) do names[#names + 1] = k end
690
+ table.sort(names)
691
+ return nil, fmt .. " bitDepth must be one of " .. table.concat(names, ", ") .. " — nothing changed"
692
+ end
693
+ end
694
+ if p.mp3Bitrate ~= nil then
695
+ if fmt ~= "mp3" then return nil, "mp3Bitrate applies to format mp3 (format is " .. tostring(fmt) .. ") — nothing changed" end
696
+ local ok = false
697
+ for _, kb in ipairs(MP3_BITRATES) do if kb == p.mp3Bitrate then ok = true end end
698
+ if not ok then return nil, "mp3Bitrate must be one of " .. table.concat(MP3_BITRATES, ", ") .. " kbps — nothing changed" end
699
+ end
700
+ if p.bitDepth ~= nil or p.mp3Bitrate ~= nil then
701
+ blob = build_format(fmt, cur.format == fmt and cur_raw ~= M.FORMATS[fmt] and cur_raw or nil, tostring(p.bitDepth), p.mp3Bitrate)
702
+ end
703
+ local err = bool_or_nil(p, { "dither", "noiseShaping", "normalizeOnlyTooLoud", "limit", "limitTruePeak", "embedMetadata" })
704
+ or number_in(p, "normalizeTarget", -70, 0, " dB")
705
+ or number_in(p, "limitCeiling", -30, 0, " dB")
706
+ if err then return nil, err end
707
+ if p.normalize ~= nil and p.normalize ~= "off" and M.NORMALIZE[p.normalize] == nil then
708
+ return nil, "unknown normalize '" .. tostring(p.normalize) .. "' (use off, lufs_i, rms, peak, true_peak, lufs_m_max or lufs_s_max) — nothing changed"
709
+ end
710
+ if p.normalize ~= nil and p.normalize ~= "off" and p.normalizeTarget == nil and cur.normalizeTarget == nil then
711
+ return nil, "normalize needs normalizeTarget (dB) — nothing changed"
712
+ end
713
+ if p.limit == true and p.limitCeiling == nil and cur.limitCeiling == nil then
714
+ return nil, "limit needs limitCeiling (dB) — nothing changed"
715
+ end
716
+ local meta = {}
717
+ if p.metadata ~= nil then
718
+ if type(p.metadata) ~= "table" then return nil, "metadata must be an object — nothing changed" end
719
+ for field, v in pairs(p.metadata) do
720
+ local keys = M.METADATA[field]
721
+ if not keys then
722
+ local names = {}
723
+ for k in pairs(M.METADATA) do names[#names + 1] = k end
724
+ table.sort(names)
725
+ return nil, "unknown metadata field '" .. tostring(field) .. "' (use " .. table.concat(names, ", ") .. ", or metadataRaw for exact keys) — nothing changed"
726
+ end
727
+ if type(v) ~= "string" then return nil, "metadata." .. field .. " must be a string — nothing changed" end
728
+ for _, k in ipairs(keys) do meta[#meta + 1] = { k, v } end
729
+ end
730
+ end
731
+ if p.metadataRaw ~= nil then
732
+ if type(p.metadataRaw) ~= "table" then return nil, "metadataRaw must be an object of KEY → value — nothing changed" end
733
+ for k, v in pairs(p.metadataRaw) do
734
+ if type(k) ~= "string" or not k:match("^%u[%u%d]*:[^|;]+$") then
735
+ return nil, "metadataRaw key '" .. tostring(k) .. "' must look like SCHEME:FIELD (e.g. ID3:TCOM) — nothing changed"
736
+ end
737
+ if type(v) ~= "string" then return nil, "metadataRaw['" .. k .. "'] must be a string — nothing changed" end
738
+ meta[#meta + 1] = { k, v }
739
+ end
740
+ end
741
+ for _, kv in ipairs(meta) do
742
+ if kv[2]:find("[\r\n]") then return nil, "metadata values must be one line (" .. kv[1] .. ") — nothing changed" end
743
+ end
744
+ local matrix
745
+ if p.regionMatrix ~= nil then
746
+ if not resolve then return nil, "regionMatrix needs a track resolver — nothing changed" end
747
+ local m, merr = resolve_matrix(api, p.regionMatrix, resolve)
748
+ if not m then return nil, merr end
749
+ matrix = m
750
+ end
751
+
752
+ edit(api, "Set render settings", function()
753
+ if p.outputDirectory ~= nil then api.GetSetProjectInfo_String(0, "RENDER_FILE", p.outputDirectory, true) end
754
+ if p.filePattern ~= nil then api.GetSetProjectInfo_String(0, "RENDER_PATTERN", p.filePattern, true) end
755
+ if blob then api.GetSetProjectInfo_String(0, "RENDER_FORMAT", blob, true)
756
+ elseif fourcc and fourcc ~= M.FORMATS[cur.format] then
757
+ -- The bare fourcc means the format's defaults: write it only on a real change, or a restated
758
+ -- format would drop the bit depth or bitrate already set
759
+ api.GetSetProjectInfo_String(0, "RENDER_FORMAT", fourcc, true)
760
+ end
761
+ if fourcc2 then api.GetSetProjectInfo_String(0, "RENDER_FORMAT2", fourcc2, true) end
762
+ if p.bounds ~= nil then api.GetSetProjectInfo(0, "RENDER_BOUNDSFLAG", M.BOUNDS[p.bounds], true) end
763
+ if p.startTime ~= nil then api.GetSetProjectInfo(0, "RENDER_STARTPOS", p.startTime, true) end
764
+ if p.endTime ~= nil then api.GetSetProjectInfo(0, "RENDER_ENDPOS", p.endTime, true) end
765
+ if p.sampleRate ~= nil then api.GetSetProjectInfo(0, "RENDER_SRATE", p.sampleRate, true) end
766
+ if p.channels ~= nil then api.GetSetProjectInfo(0, "RENDER_CHANNELS", p.channels, true) end
767
+ local settings = math.floor(api.GetSetProjectInfo(0, "RENDER_SETTINGS", 0, false))
768
+ local new = settings
769
+ if p.source ~= nil then new = (new & ~SOURCE_MASK) | M.SOURCES[p.source] end
770
+ local embed = p.embedMetadata
771
+ if embed == nil and #meta > 0 then embed = true end
772
+ if embed ~= nil then new = embed and (new | EMBED_METADATA) or (new & ~EMBED_METADATA) end
773
+ if new ~= settings then api.GetSetProjectInfo(0, "RENDER_SETTINGS", new, true) end
774
+ if p.dither ~= nil or p.noiseShaping ~= nil then
775
+ local d = math.floor(api.GetSetProjectInfo(0, "RENDER_DITHER", 0, false))
776
+ if p.dither ~= nil then d = p.dither and (d | 1 | 4) or (d & ~(1 | 4)) end
777
+ if p.noiseShaping ~= nil then d = p.noiseShaping and (d | 2 | 8) or (d & ~(2 | 8)) end
778
+ api.GetSetProjectInfo(0, "RENDER_DITHER", d, true)
779
+ end
780
+ if p.normalize ~= nil or p.normalizeOnlyTooLoud ~= nil or p.limit ~= nil or p.limitTruePeak ~= nil then
781
+ local n = math.floor(api.GetSetProjectInfo(0, "RENDER_NORMALIZE", 0, false))
782
+ if p.normalize == "off" then n = n & ~NORM_ON
783
+ elseif p.normalize ~= nil then n = (n & ~NORM_MODE) | NORM_ON | M.NORMALIZE[p.normalize] end
784
+ if p.normalizeOnlyTooLoud ~= nil then n = p.normalizeOnlyTooLoud and (n | NORM_TOO_LOUD) or (n & ~NORM_TOO_LOUD) end
785
+ if p.limit ~= nil then n = p.limit and (n | NORM_LIMIT) or (n & ~NORM_LIMIT) end
786
+ if p.limitTruePeak ~= nil then n = p.limitTruePeak and (n | NORM_LIMIT_TP) or (n & ~NORM_LIMIT_TP) end
787
+ api.GetSetProjectInfo(0, "RENDER_NORMALIZE", n, true)
788
+ end
789
+ if p.normalizeTarget ~= nil then api.GetSetProjectInfo(0, "RENDER_NORMALIZE_TARGET", 10 ^ (p.normalizeTarget / 20), true) end
790
+ if p.limitCeiling ~= nil then api.GetSetProjectInfo(0, "RENDER_BRICKWALL", 10 ^ (p.limitCeiling / 20), true) end
791
+ for _, kv in ipairs(meta) do api.GetSetProjectInfo_String(0, "RENDER_METADATA", kv[1] .. "|" .. kv[2], true) end
792
+ for _, row in ipairs(matrix or {}) do
793
+ -- Replace the region's row: drop what it rendered, then add the listed tracks
794
+ local old = {}
795
+ local j = 0
796
+ while true do
797
+ local tr = api.EnumRegionRenderMatrix(0, row.region, j)
798
+ if not tr then break end
799
+ old[#old + 1] = tr
800
+ j = j + 1
801
+ end
802
+ for _, tr in ipairs(old) do api.SetRegionRenderMatrix(0, row.region, tr, -1) end
803
+ for _, tr in ipairs(row.tracks) do api.SetRegionRenderMatrix(0, row.region, tr, 1) end
804
+ end
805
+ end)
806
+ return M.get_render(api)
807
+ end
808
+
809
+ -- Loudness of a rendered file through REAPER's own normalization scan (any format REAPER reads)
810
+ local function measure_file(api, path)
811
+ if not (api.PCM_Source_CreateFromFile and api.CalculateNormalization) then return { file = path } end
812
+ local src = api.PCM_Source_CreateFromFile(path)
813
+ if not src then return { file = path } end
814
+ local out = { file = path, durationSeconds = math.floor(api.GetMediaSourceLength(src) * 1000 + 0.5) / 1000 }
815
+ local function level(mode)
816
+ local g = api.CalculateNormalization(src, mode, 0, 0, 0)
817
+ if type(g) ~= "number" or g <= 0 or g ~= g or g == math.huge then return nil end
818
+ return math.floor(-20 * math.log(g, 10) * 100 + 0.5) / 100
819
+ end
820
+ out.lufsIntegrated = level(0)
821
+ out.truePeak = level(3)
822
+ out.samplePeak = level(2)
823
+ api.PCM_Source_Destroy(src)
824
+ return out
825
+ end
826
+
827
+ local function is_absolute(path) return path:sub(1, 1) == "/" or path:match("^%a:[/\\]") ~= nil end
828
+
829
+ -- Everything render_core refuses before REAPER renders, as a reason (nil = fine)
830
+ local function render_refusal(api, settings, targets, fs, overwrite, hint)
831
+ if #targets == 0 then
832
+ if settings.source == "stems" or settings.source == "selected_via_master" or settings.source == "master_and_stems" then
833
+ return "refused: source " .. settings.source .. " renders the selected tracks and none is selected — " .. (hint or "select tracks or pass tracks")
834
+ end
835
+ if settings.source == "selected_items" or settings.source == "selected_items_via_master" then
836
+ return "refused: source " .. settings.source .. " renders the selected items and none is selected — select items with set_selected_items"
837
+ end
838
+ if settings.source == "region_matrix" then
839
+ return "refused: the region render matrix lists no track for the rendered regions — set regionMatrix with set_render_settings"
840
+ end
841
+ return "refused: the render settings name no output file — set outputDirectory and filePattern with set_render_settings"
842
+ end
843
+ for _, t in ipairs(targets) do
844
+ if not is_absolute(t) then
845
+ return "refused: render target '" .. t .. "' is not an absolute path (unsaved project?) — set outputDirectory to an absolute directory"
846
+ end
847
+ end
848
+ -- Seen live: a render whose secondary format was a sink outside this list
849
+ -- (a project template's video sink) left a modal dialog open.
850
+ for _, f in ipairs({ settings.format or "unset", settings.secondaryFormat }) do
851
+ if f == "unset" or f:sub(1, 6) == "other:" then
852
+ return "refused: render format '" .. f .. "' is not one of wav, aiff, flac, mp3, ogg, opus (other encoders can open a dialog) — set format / secondaryFormat with set_render_settings"
853
+ end
854
+ end
855
+ if settings.bounds == "time_selection" then
856
+ local s, e = api.GetSet_LoopTimeRange(false, false, 0, 0, false)
857
+ if e <= s then return "refused: bounds is time_selection but there is no time selection" end
858
+ elseif settings.bounds == "custom" and settings.endTime <= settings.startTime then
859
+ return "refused: custom bounds have endTime <= startTime"
860
+ end
861
+ if overwrite ~= true then
862
+ local existing = {}
863
+ for _, t in ipairs(targets) do
864
+ if fs.exists(t) then existing[#existing + 1] = t end
865
+ end
866
+ if #existing > 0 then
867
+ return "refused: " .. table.concat(existing, ", ") .. " already exists (REAPER would ask whether to overwrite) — pass overwrite: true to replace"
868
+ end
869
+ end
870
+ end
871
+
872
+ -- Render with the current settings once the checks passed: delete existing targets
873
+ -- (overwrite), render, then measure what was written
874
+ local function render_core(api, settings, targets, fs, p)
875
+ local existing, deleted = {}, {}
876
+ for _, t in ipairs(targets) do
877
+ if fs.exists(t) then existing[#existing + 1] = t end
878
+ end
879
+ for _, t in ipairs(existing) do
880
+ fs.remove(t)
881
+ -- A file that survives (open elsewhere, read-only) would make REAPER prompt.
882
+ if fs.exists(t) then
883
+ return nil, "refused: could not delete " .. t .. " to overwrite it (open in another program or read-only?)"
884
+ .. (#deleted > 0 and ("; already deleted: " .. table.concat(deleted, ", ")) or "")
885
+ end
886
+ deleted[#deleted + 1] = t
887
+ end
888
+ for _, t in ipairs(targets) do
889
+ local dir = parent_dir(t)
890
+ if dir and api.RecursiveCreateDirectory then api.RecursiveCreateDirectory(dir, 0) end
891
+ end
892
+
893
+ -- 42230: File: Render project, using the most recent render settings, auto-close render dialog.
894
+ api.Main_OnCommand(42230, 0)
895
+
896
+ local files, missing = {}, {}
897
+ for _, t in ipairs(targets) do
898
+ if fs.exists(t) then files[#files + 1] = t else missing[#missing + 1] = t end
899
+ end
900
+ if #files == 0 then return nil, "render produced no file (expected " .. table.concat(targets, ", ") .. ")" end
901
+ local out = { rendered = true, files = files, missing = missing, bounds = settings.bounds, format = settings.format, source = settings.source }
902
+ out.bitDepth, out.mp3Bitrate = settings.bitDepth, settings.mp3Bitrate
903
+ if settings.normalize ~= "off" then out.normalize, out.normalizeTarget = settings.normalize, settings.normalizeTarget end
904
+ if settings.limit then out.limitCeiling, out.limitTruePeak = settings.limitCeiling, settings.limitTruePeak end
905
+ if p.analyze ~= false then
906
+ out.analysis = {}
907
+ for i = 1, math.min(#files, MAX_ANALYZED) do out.analysis[i] = measure_file(api, files[i]) end
908
+ if #files > MAX_ANALYZED then out.analysisSkipped = #files - MAX_ANALYZED end
909
+ end
910
+ return out
911
+ end
912
+
913
+ -- Selection of every track (the master included), to put back after a render that selected `tracks`
914
+ local function save_selection(api)
915
+ local sel = { master = api.IsTrackSelected(api.GetMasterTrack(0)) }
916
+ for i = 0, api.CountTracks(0) - 1 do
917
+ local tr = api.GetTrack(0, i)
918
+ sel[#sel + 1] = { tr, api.IsTrackSelected(tr) }
919
+ end
920
+ return sel
921
+ end
922
+
923
+ local function restore_selection(api, sel)
924
+ for _, s in ipairs(sel) do api.SetTrackSelected(s[1], s[2]) end
925
+ api.SetTrackSelected(api.GetMasterTrack(0), sel.master == true)
926
+ end
927
+
928
+ local function resolve_all(api, refs, resolve, what)
929
+ if type(refs) ~= "table" then return nil, what .. " must be a list of track references" end
930
+ local out = {}
931
+ for _, ref in ipairs(refs) do
932
+ local res, err = resolve(api, ref, {})
933
+ if not res or not res.track then return nil, what .. ": " .. tostring(err or ("track " .. tostring(ref) .. " not found")) end
934
+ out[#out + 1] = res.track
935
+ end
936
+ return out
937
+ end
938
+
939
+ local SELECTION_SOURCES = { stems = true, master_and_stems = true, selected_via_master = true }
940
+
941
+ --- render_project: render with the project's current render settings
942
+ --- (synchronous; blocks REAPER until done). Refuses when a target file
943
+ --- already exists (REAPER would ask whether to overwrite) unless overwrite,
944
+ --- which deletes those files first. `tracks` selects the tracks a stems
945
+ --- render uses, for this render only. Each file written is measured
946
+ --- (duration, LUFS-I, true peak, sample peak) unless analyze is false.
947
+ function M.render(api, p, fs, resolve)
948
+ local settings = M.get_render(api)
949
+ if p.tracks ~= nil then
950
+ if not SELECTION_SOURCES[settings.source] then
951
+ return nil, "refused: tracks applies to source stems, master_and_stems or selected_via_master (source is " .. settings.source .. ")"
952
+ end
953
+ if not resolve then return nil, "refused: tracks needs a track resolver" end
954
+ local tracks, err = resolve_all(api, p.tracks, resolve, "tracks")
955
+ if not tracks then return nil, "refused: " .. err end
956
+ if #tracks == 0 then return nil, "refused: tracks is empty" end
957
+ local sel = save_selection(api)
958
+ for _, s in ipairs(sel) do api.SetTrackSelected(s[1], false) end
959
+ api.SetTrackSelected(api.GetMasterTrack(0), false)
960
+ for _, tr in ipairs(tracks) do api.SetTrackSelected(tr, true) end
961
+ local ok, out, rerr = pcall(function()
962
+ local targets = render_targets(api)
963
+ local refusal = render_refusal(api, settings, targets, fs, p.overwrite)
964
+ if refusal then return nil, refusal end
965
+ return render_core(api, settings, targets, fs, p)
966
+ end)
967
+ restore_selection(api, sel)
968
+ if not ok then error(out, 0) end
969
+ return out, rerr
970
+ end
971
+ local targets = settings.targets
972
+ local refusal = render_refusal(api, settings, targets, fs, p.overwrite)
973
+ if refusal then return nil, refusal end
974
+ return render_core(api, settings, targets, fs, p)
975
+ end
976
+
977
+ local MAX_VERSIONS = 16
978
+
979
+ -- The bridge decodes a command's top level only (no JSON parser in REAPER), so
980
+ -- nested params arrive as JSON strings; decode the ones the render tools take.
981
+ local JSON_FIELDS = { "metadata", "metadataRaw", "regionMatrix", "tracks", "versions" }
982
+ function M.decode_json_fields(p, json_decode)
983
+ for _, k in ipairs(JSON_FIELDS) do
984
+ if type(p[k]) == "string" then
985
+ local ok, v, err = pcall(json_decode, p[k])
986
+ if not ok or type(v) ~= "table" then
987
+ return nil, k .. " is not valid JSON" .. ((err or (not ok and v)) and (" (" .. tostring(err or (type(v) == "table" and v.json) or v) .. ")") or "") .. " — nothing changed"
988
+ end
989
+ p[k] = v
990
+ end
991
+ end
992
+ return p
993
+ end
994
+
995
+ --- render_versions: one render per version, each with its own tracks muted
996
+ --- (an instrumental mutes the vocals, a TV mix the lead vocal, …) and the
997
+ --- file pattern suffixed with the version name. Every reference, name and
998
+ --- target is checked before the first render; mutes and the file pattern are
999
+ --- put back afterwards, also when a render fails.
1000
+ function M.render_versions(api, p, fs, resolve)
1001
+ local versions = p.versions
1002
+ if type(versions) ~= "table" or #versions == 0 then return nil, "versions must be a non-empty list of { name, mute }" end
1003
+ if #versions > MAX_VERSIONS then return nil, "at most " .. MAX_VERSIONS .. " versions per call" end
1004
+ local settings = M.get_render(api)
1005
+ local plan, seen = {}, {}
1006
+ for i, v in ipairs(versions) do
1007
+ if type(v) ~= "table" or type(v.name) ~= "string" or not v.name:match("^[%w][%w _%-%.]*$") then
1008
+ return nil, "versions[" .. i .. "].name must be letters, digits, space, - _ or . (it names the files) — nothing rendered"
1009
+ end
1010
+ if seen[v.name:lower()] then return nil, "version name '" .. v.name .. "' is used twice — nothing rendered" end
1011
+ seen[v.name:lower()] = true
1012
+ if v.filePattern ~= nil and (type(v.filePattern) ~= "string" or v.filePattern == "") then
1013
+ return nil, "versions[" .. i .. "].filePattern must be a non-empty string — nothing rendered"
1014
+ end
1015
+ local muted, err = resolve_all(api, v.mute or {}, resolve, "versions[" .. i .. "].mute")
1016
+ if not muted then return nil, err .. " — nothing rendered" end
1017
+ plan[i] = { name = v.name, pattern = v.filePattern or (settings.filePattern .. "-" .. v.name), muted = muted }
1018
+ end
1019
+
1020
+ local base_pattern = settings.filePattern
1021
+ local mutes = {}
1022
+ for i = 0, api.CountTracks(0) - 1 do
1023
+ local tr = api.GetTrack(0, i)
1024
+ mutes[#mutes + 1] = { tr, api.GetMediaTrackInfo_Value(tr, "B_MUTE") }
1025
+ end
1026
+ local master = api.GetMasterTrack(0)
1027
+ local master_mute = api.GetMediaTrackInfo_Value(master, "B_MUTE")
1028
+ local function restore()
1029
+ for _, m in ipairs(mutes) do
1030
+ if api.GetMediaTrackInfo_Value(m[1], "B_MUTE") ~= m[2] then api.SetMediaTrackInfo_Value(m[1], "B_MUTE", m[2]) end
1031
+ end
1032
+ if api.GetMediaTrackInfo_Value(master, "B_MUTE") ~= master_mute then api.SetMediaTrackInfo_Value(master, "B_MUTE", master_mute) end
1033
+ api.GetSetProjectInfo_String(0, "RENDER_PATTERN", base_pattern, true)
1034
+ end
1035
+
1036
+ local results = {}
1037
+ local ok, err = pcall(function()
1038
+ -- Targets per version (the pattern decides them), all checked before the first render
1039
+ local seen_target = {}
1040
+ for _, v in ipairs(plan) do
1041
+ api.GetSetProjectInfo_String(0, "RENDER_PATTERN", v.pattern, true)
1042
+ v.targets = render_targets(api)
1043
+ local refusal = render_refusal(api, settings, v.targets, fs, p.overwrite, "select the tracks to render first")
1044
+ if refusal then error({ refusal = "version " .. v.name .. ": " .. refusal .. " — nothing rendered" }) end
1045
+ for _, t in ipairs(v.targets) do
1046
+ -- Lowercased: paths differing only in case are one file on APFS and NTFS
1047
+ local key = t:lower()
1048
+ if seen_target[key] then error({ refusal = "versions " .. seen_target[key] .. " and " .. v.name .. " would both write " .. t .. " — give them distinct filePattern — nothing rendered" }) end
1049
+ seen_target[key] = v.name
1050
+ end
1051
+ end
1052
+ for _, v in ipairs(plan) do
1053
+ local mute_set = {}
1054
+ for _, tr in ipairs(v.muted) do mute_set[tr] = true end
1055
+ for _, m in ipairs(mutes) do
1056
+ local want = mute_set[m[1]] and 1 or m[2]
1057
+ if api.GetMediaTrackInfo_Value(m[1], "B_MUTE") ~= want then api.SetMediaTrackInfo_Value(m[1], "B_MUTE", want) end
1058
+ end
1059
+ local want_master = mute_set[master] and 1 or master_mute
1060
+ if api.GetMediaTrackInfo_Value(master, "B_MUTE") ~= want_master then api.SetMediaTrackInfo_Value(master, "B_MUTE", want_master) end
1061
+ api.GetSetProjectInfo_String(0, "RENDER_PATTERN", v.pattern, true)
1062
+ local out, rerr = render_core(api, settings, v.targets, fs, p)
1063
+ if not out then
1064
+ local done = {}
1065
+ for _, r in ipairs(results) do done[#done + 1] = r.name end
1066
+ error({ refusal = "version " .. v.name .. ": " .. rerr .. (#done > 0 and ("; rendered before it: " .. table.concat(done, ", ")) or "") })
1067
+ end
1068
+ local mutedNames = {}
1069
+ for _, tr in ipairs(v.muted) do
1070
+ if tr == master then mutedNames[#mutedNames + 1] = "master"
1071
+ else local _, n = api.GetTrackName(tr); mutedNames[#mutedNames + 1] = n or "" end
1072
+ end
1073
+ results[#results + 1] = { name = v.name, muted = mutedNames, files = out.files, missing = out.missing, analysis = out.analysis }
1074
+ end
1075
+ end)
1076
+ restore()
1077
+ if not ok then
1078
+ if type(err) == "table" and err.refusal then return nil, err.refusal end
1079
+ error(err, 0)
1080
+ end
1081
+ local out = { rendered = true, versions = results, format = settings.format, source = settings.source }
1082
+ out.bitDepth, out.mp3Bitrate = settings.bitDepth, settings.mp3Bitrate
1083
+ if settings.normalize ~= "off" then out.normalize, out.normalizeTarget = settings.normalize, settings.normalizeTarget end
1084
+ if settings.limit then out.limitCeiling, out.limitTruePeak = settings.limitCeiling, settings.limitTruePeak end
1085
+ return out
1086
+ end
1087
+
1088
+ return M