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