@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
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
-- =============================================================================
|
|
2
2
|
-- MCP Snapshot Library — shared helpers for snapshot action scripts
|
|
3
3
|
-- =============================================================================
|
|
4
|
-
--
|
|
5
|
-
--
|
|
4
|
+
-- Single source of truth for path resolution, JSON encode/decode, listing,
|
|
5
|
+
-- mixer-state capture, full restore, and save. Loaded via dofile() by the
|
|
6
|
+
-- snapshot action scripts (next/prev/quick-save), so all of them share ONE save/restore implementation (no drift).
|
|
7
|
+
--
|
|
8
|
+
-- Uses only built-in REAPER APIs (no ReaImGui, no SWS required). CF_Json_Parse
|
|
9
|
+
-- is used opportunistically when present, but a pure-Lua decoder is the
|
|
10
|
+
-- guaranteed fallback.
|
|
6
11
|
-- =============================================================================
|
|
7
12
|
|
|
8
13
|
local M = {}
|
|
9
14
|
|
|
10
|
-
-- ExtState section for persisting current snapshot
|
|
15
|
+
-- ExtState section for persisting the current snapshot across action calls.
|
|
16
|
+
-- Keyed by snapshot NAME (not list position) so add/delete/re-sort can't make
|
|
17
|
+
-- next/prev jump to an unrelated snapshot.
|
|
11
18
|
M.EXT_SECTION = "MCP_Snapshots"
|
|
12
|
-
M.
|
|
19
|
+
M.EXT_KEY_NAME = "current_name"
|
|
13
20
|
|
|
14
21
|
-- =============================================================================
|
|
15
22
|
-- Path helpers
|
|
@@ -52,8 +59,88 @@ function M.write_file(path, content)
|
|
|
52
59
|
return true
|
|
53
60
|
end
|
|
54
61
|
|
|
62
|
+
-- Atomic write: write to a temp file, then rename over the target. Prevents a
|
|
63
|
+
-- crash mid-write from corrupting (or truncating) an existing snapshot.
|
|
64
|
+
function M.write_file_atomic(path, content)
|
|
65
|
+
local tmp = path .. ".tmp"
|
|
66
|
+
local f = io.open(tmp, "wb")
|
|
67
|
+
if not f then
|
|
68
|
+
return M.write_file(path, content) -- temp not writable; best-effort direct
|
|
69
|
+
end
|
|
70
|
+
f:write(content)
|
|
71
|
+
f:close()
|
|
72
|
+
-- POSIX rename atomically replaces; Windows rename fails if dest exists, so
|
|
73
|
+
-- remove first and retry. Fall back to a direct write if rename can't work.
|
|
74
|
+
local ok = os.rename(tmp, path)
|
|
75
|
+
if not ok then
|
|
76
|
+
os.remove(path)
|
|
77
|
+
ok = os.rename(tmp, path)
|
|
78
|
+
end
|
|
79
|
+
if not ok then
|
|
80
|
+
os.remove(tmp)
|
|
81
|
+
return M.write_file(path, content)
|
|
82
|
+
end
|
|
83
|
+
return true
|
|
84
|
+
end
|
|
85
|
+
|
|
55
86
|
-- =============================================================================
|
|
56
|
-
--
|
|
87
|
+
-- Base64 (standard alphabet)
|
|
88
|
+
-- =============================================================================
|
|
89
|
+
-- Used to store REAPER track state chunks inside the snapshot JSON: chunks
|
|
90
|
+
-- contain quotes, newlines and {GUID} braces that a naive JSON round-trip can
|
|
91
|
+
-- mangle. Base64's alphabet (A-Za-z0-9+/=) is safe for both the encoder above
|
|
92
|
+
-- and the decoder below. Mirrors the implementation in mcp_bridge.lua so the
|
|
93
|
+
-- action scripts and the MCP tool capture plugin internal state identically.
|
|
94
|
+
|
|
95
|
+
local B64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
|
96
|
+
local B64_LOOKUP = {}
|
|
97
|
+
for _b = 1, #B64_CHARS do B64_LOOKUP[B64_CHARS:sub(_b, _b)] = _b - 1 end
|
|
98
|
+
|
|
99
|
+
function M.base64_encode(data)
|
|
100
|
+
if not data or data == "" then return "" end
|
|
101
|
+
local out = {}
|
|
102
|
+
local len = #data
|
|
103
|
+
local i = 1
|
|
104
|
+
while i <= len do
|
|
105
|
+
local b1 = data:byte(i)
|
|
106
|
+
local b2 = (i + 1 <= len) and data:byte(i + 1) or 0
|
|
107
|
+
local b3 = (i + 2 <= len) and data:byte(i + 2) or 0
|
|
108
|
+
local n = b1 * 65536 + b2 * 256 + b3
|
|
109
|
+
local c1 = math.floor(n / 262144) % 64
|
|
110
|
+
local c2 = math.floor(n / 4096) % 64
|
|
111
|
+
local c3 = math.floor(n / 64) % 64
|
|
112
|
+
local c4 = n % 64
|
|
113
|
+
out[#out + 1] = B64_CHARS:sub(c1 + 1, c1 + 1)
|
|
114
|
+
out[#out + 1] = B64_CHARS:sub(c2 + 1, c2 + 1)
|
|
115
|
+
out[#out + 1] = (i + 1 <= len) and B64_CHARS:sub(c3 + 1, c3 + 1) or '='
|
|
116
|
+
out[#out + 1] = (i + 2 <= len) and B64_CHARS:sub(c4 + 1, c4 + 1) or '='
|
|
117
|
+
i = i + 3
|
|
118
|
+
end
|
|
119
|
+
return table.concat(out)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
function M.base64_decode(data)
|
|
123
|
+
if not data or data == "" then return "" end
|
|
124
|
+
data = data:gsub('[^A-Za-z0-9+/]', '') -- strip padding/whitespace/newlines
|
|
125
|
+
local out = {}
|
|
126
|
+
local len = #data
|
|
127
|
+
local i = 1
|
|
128
|
+
while i <= len do
|
|
129
|
+
local c1 = B64_LOOKUP[data:sub(i, i)] or 0
|
|
130
|
+
local c2 = B64_LOOKUP[data:sub(i + 1, i + 1)] or 0
|
|
131
|
+
local c3 = B64_LOOKUP[data:sub(i + 2, i + 2)]
|
|
132
|
+
local c4 = B64_LOOKUP[data:sub(i + 3, i + 3)]
|
|
133
|
+
local n = c1 * 262144 + c2 * 4096 + (c3 or 0) * 64 + (c4 or 0)
|
|
134
|
+
out[#out + 1] = string.char(math.floor(n / 65536) % 256)
|
|
135
|
+
if c3 ~= nil then out[#out + 1] = string.char(math.floor(n / 256) % 256) end
|
|
136
|
+
if c4 ~= nil then out[#out + 1] = string.char(n % 256) end
|
|
137
|
+
i = i + 4
|
|
138
|
+
end
|
|
139
|
+
return table.concat(out)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
-- =============================================================================
|
|
143
|
+
-- Minimal JSON encode
|
|
57
144
|
-- =============================================================================
|
|
58
145
|
|
|
59
146
|
local function json_encode_string(s)
|
|
@@ -71,7 +158,8 @@ function M.json_encode(val)
|
|
|
71
158
|
if t == "nil" then return "null"
|
|
72
159
|
elseif t == "boolean" then return val and "true" or "false"
|
|
73
160
|
elseif t == "number" then
|
|
74
|
-
if val ~= val then return "null" end
|
|
161
|
+
if val ~= val then return "null" end -- NaN
|
|
162
|
+
if val == math.huge or val == -math.huge then return "null" end -- inf
|
|
75
163
|
return tostring(val)
|
|
76
164
|
elseif t == "string" then return json_encode_string(val)
|
|
77
165
|
elseif t == "table" then
|
|
@@ -98,6 +186,138 @@ function M.json_encode(val)
|
|
|
98
186
|
return "null"
|
|
99
187
|
end
|
|
100
188
|
|
|
189
|
+
-- =============================================================================
|
|
190
|
+
-- JSON decode
|
|
191
|
+
-- =============================================================================
|
|
192
|
+
-- A real recursive-descent decoder: string-aware (braces/brackets inside string
|
|
193
|
+
-- values never confuse it) and arbitrarily nested. This is what lets restore
|
|
194
|
+
-- read the full mixerState (FX params, sends) safely — the old brace-counting
|
|
195
|
+
-- approach miscounted on FX names containing { } [ ] characters.
|
|
196
|
+
|
|
197
|
+
local function json_decode_pure(str)
|
|
198
|
+
local pos = 1
|
|
199
|
+
local len = #str
|
|
200
|
+
local parse_value -- forward declaration
|
|
201
|
+
|
|
202
|
+
local function skip_ws()
|
|
203
|
+
local _, e = str:find('^[ \t\r\n]+', pos)
|
|
204
|
+
if e then pos = e + 1 end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
local function parse_string()
|
|
208
|
+
pos = pos + 1 -- skip opening quote
|
|
209
|
+
local buf = {}
|
|
210
|
+
while pos <= len do
|
|
211
|
+
local ch = str:sub(pos, pos)
|
|
212
|
+
if ch == '"' then
|
|
213
|
+
pos = pos + 1
|
|
214
|
+
return table.concat(buf)
|
|
215
|
+
elseif ch == '\\' then
|
|
216
|
+
local nx = str:sub(pos + 1, pos + 1)
|
|
217
|
+
if nx == 'n' then buf[#buf + 1] = '\n'; pos = pos + 2
|
|
218
|
+
elseif nx == 't' then buf[#buf + 1] = '\t'; pos = pos + 2
|
|
219
|
+
elseif nx == 'r' then buf[#buf + 1] = '\r'; pos = pos + 2
|
|
220
|
+
elseif nx == 'b' then buf[#buf + 1] = '\b'; pos = pos + 2
|
|
221
|
+
elseif nx == 'f' then buf[#buf + 1] = '\f'; pos = pos + 2
|
|
222
|
+
elseif nx == 'u' then
|
|
223
|
+
local cp = tonumber(str:sub(pos + 2, pos + 5), 16)
|
|
224
|
+
if cp and cp < 128 then buf[#buf + 1] = string.char(cp)
|
|
225
|
+
else buf[#buf + 1] = '?' end -- non-ASCII \u (our encoder never emits these)
|
|
226
|
+
pos = pos + 6
|
|
227
|
+
else buf[#buf + 1] = nx; pos = pos + 2 end -- \" \\ \/ and any other
|
|
228
|
+
else
|
|
229
|
+
buf[#buf + 1] = ch
|
|
230
|
+
pos = pos + 1
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
return table.concat(buf) -- unterminated; best-effort
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
local function parse_number()
|
|
237
|
+
local num_str = str:match('^%-?%d+%.?%d*[eE]?[%-+]?%d*', pos)
|
|
238
|
+
if not num_str or num_str == "" then
|
|
239
|
+
pos = pos + 1
|
|
240
|
+
return 0
|
|
241
|
+
end
|
|
242
|
+
pos = pos + #num_str
|
|
243
|
+
return tonumber(num_str) or 0
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
local function parse_object()
|
|
247
|
+
pos = pos + 1 -- skip {
|
|
248
|
+
local obj = {}
|
|
249
|
+
skip_ws()
|
|
250
|
+
if str:sub(pos, pos) == '}' then pos = pos + 1; return obj end
|
|
251
|
+
while pos <= len do
|
|
252
|
+
skip_ws()
|
|
253
|
+
if str:sub(pos, pos) ~= '"' then break end
|
|
254
|
+
local key = parse_string()
|
|
255
|
+
skip_ws()
|
|
256
|
+
if str:sub(pos, pos) ~= ':' then break end
|
|
257
|
+
pos = pos + 1
|
|
258
|
+
obj[key] = parse_value()
|
|
259
|
+
skip_ws()
|
|
260
|
+
local c = str:sub(pos, pos)
|
|
261
|
+
if c == ',' then pos = pos + 1
|
|
262
|
+
elseif c == '}' then pos = pos + 1; return obj
|
|
263
|
+
else break end
|
|
264
|
+
end
|
|
265
|
+
return obj
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
local function parse_array()
|
|
269
|
+
pos = pos + 1 -- skip [
|
|
270
|
+
local arr = {}
|
|
271
|
+
skip_ws()
|
|
272
|
+
if str:sub(pos, pos) == ']' then pos = pos + 1; return arr end
|
|
273
|
+
while pos <= len do
|
|
274
|
+
arr[#arr + 1] = parse_value()
|
|
275
|
+
skip_ws()
|
|
276
|
+
local c = str:sub(pos, pos)
|
|
277
|
+
if c == ',' then pos = pos + 1
|
|
278
|
+
elseif c == ']' then pos = pos + 1; return arr
|
|
279
|
+
else break end
|
|
280
|
+
end
|
|
281
|
+
return arr
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
parse_value = function()
|
|
285
|
+
skip_ws()
|
|
286
|
+
local ch = str:sub(pos, pos)
|
|
287
|
+
if ch == '{' then return parse_object()
|
|
288
|
+
elseif ch == '[' then return parse_array()
|
|
289
|
+
elseif ch == '"' then return parse_string()
|
|
290
|
+
elseif ch == '-' or ch:match('%d') then return parse_number()
|
|
291
|
+
else
|
|
292
|
+
local word = str:match('^[%a]+', pos)
|
|
293
|
+
if word == 'true' then pos = pos + 4; return true
|
|
294
|
+
elseif word == 'false' then pos = pos + 5; return false
|
|
295
|
+
elseif word == 'null' then pos = pos + 4; return nil
|
|
296
|
+
else
|
|
297
|
+
pos = pos + (word and #word or 1) -- unknown bareword (inf/nan); treat as 0
|
|
298
|
+
return 0
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
return parse_value()
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
function M.json_decode(str)
|
|
307
|
+
if type(str) ~= "string" then return nil end
|
|
308
|
+
if reaper.CF_Json_Parse then
|
|
309
|
+
local ok, val = reaper.CF_Json_Parse(str)
|
|
310
|
+
if ok and type(val) == "table" then return val end
|
|
311
|
+
end
|
|
312
|
+
local ok, val = pcall(json_decode_pure, str)
|
|
313
|
+
if ok then return val end
|
|
314
|
+
return nil
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
-- =============================================================================
|
|
318
|
+
-- Flat top-level parser (cheap metadata reads for the snapshot list)
|
|
319
|
+
-- =============================================================================
|
|
320
|
+
|
|
101
321
|
local function extract_json_string(str, pos)
|
|
102
322
|
local result = {}
|
|
103
323
|
local i = pos
|
|
@@ -110,6 +330,12 @@ local function extract_json_string(str, pos)
|
|
|
110
330
|
elseif next_ch == 'n' then result[#result + 1] = '\n'
|
|
111
331
|
elseif next_ch == 't' then result[#result + 1] = '\t'
|
|
112
332
|
elseif next_ch == '/' then result[#result + 1] = '/'
|
|
333
|
+
elseif next_ch == 'r' then result[#result + 1] = '\r'
|
|
334
|
+
elseif next_ch == 'b' then result[#result + 1] = '\b'
|
|
335
|
+
elseif next_ch == 'f' then result[#result + 1] = '\f'
|
|
336
|
+
elseif next_ch == 'u' and str:sub(i + 2, i + 5):match('^%x%x%x%x$') then
|
|
337
|
+
result[#result + 1] = utf8.char(tonumber(str:sub(i + 2, i + 5), 16))
|
|
338
|
+
i = i + 4
|
|
113
339
|
else result[#result + 1] = next_ch end
|
|
114
340
|
i = i + 2
|
|
115
341
|
elseif ch == '"' then
|
|
@@ -164,6 +390,50 @@ function M.parse_flat_object(str)
|
|
|
164
390
|
return obj
|
|
165
391
|
end
|
|
166
392
|
|
|
393
|
+
-- Rough track count for legacy snapshots that don't store trackCount (capped).
|
|
394
|
+
local function count_tracks(content)
|
|
395
|
+
local mixer_start = content:find('"mixerState"')
|
|
396
|
+
if not mixer_start then return nil end
|
|
397
|
+
local tracks_start = content:find('"tracks"', mixer_start)
|
|
398
|
+
if not tracks_start then return nil end
|
|
399
|
+
local arr_start = content:find('%[', tracks_start)
|
|
400
|
+
if not arr_start then return nil end
|
|
401
|
+
local cnt, depth, i = 0, 0, arr_start
|
|
402
|
+
while i <= math.min(#content, arr_start + 50000) do
|
|
403
|
+
local ch = content:sub(i, i)
|
|
404
|
+
if ch == '[' or ch == '{' then
|
|
405
|
+
depth = depth + 1
|
|
406
|
+
if ch == '{' and depth == 2 then cnt = cnt + 1 end
|
|
407
|
+
elseif ch == ']' or ch == '}' then
|
|
408
|
+
depth = depth - 1
|
|
409
|
+
if depth == 0 then break end
|
|
410
|
+
end
|
|
411
|
+
i = i + 1
|
|
412
|
+
end
|
|
413
|
+
return cnt > 0 and cnt or nil
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
-- Read just the metadata (name/description/timestamp/trackCount) cheaply.
|
|
417
|
+
-- save_snapshot writes metadata BEFORE mixerState, so the prefix is flat and
|
|
418
|
+
-- string-safe to parse. Falls back to a whole-file flat parse for old files.
|
|
419
|
+
local function read_snapshot_meta(content)
|
|
420
|
+
local ms = content:find('"mixerState"')
|
|
421
|
+
local prefix = ms and content:sub(1, ms - 1) or content
|
|
422
|
+
local ok, obj = pcall(M.parse_flat_object, prefix)
|
|
423
|
+
if ok and obj and obj.name then
|
|
424
|
+
obj.timestamp = tonumber(obj.timestamp) or 0
|
|
425
|
+
obj.trackCount = obj.trackCount and tonumber(obj.trackCount) or nil
|
|
426
|
+
return obj
|
|
427
|
+
end
|
|
428
|
+
local ok2, obj2 = pcall(M.parse_flat_object, content)
|
|
429
|
+
if ok2 and obj2 and obj2.name then
|
|
430
|
+
obj2.timestamp = tonumber(obj2.timestamp) or 0
|
|
431
|
+
obj2.trackCount = (obj2.trackCount and tonumber(obj2.trackCount)) or count_tracks(content)
|
|
432
|
+
return obj2
|
|
433
|
+
end
|
|
434
|
+
return nil
|
|
435
|
+
end
|
|
436
|
+
|
|
167
437
|
-- =============================================================================
|
|
168
438
|
-- Snapshot listing
|
|
169
439
|
-- =============================================================================
|
|
@@ -180,167 +450,324 @@ function M.load_snapshots()
|
|
|
180
450
|
local path = snap_dir .. fn
|
|
181
451
|
local content = M.read_file(path)
|
|
182
452
|
if content then
|
|
183
|
-
local
|
|
184
|
-
if
|
|
453
|
+
local meta = read_snapshot_meta(content)
|
|
454
|
+
if meta and meta.name then
|
|
185
455
|
result[#result + 1] = {
|
|
186
|
-
name
|
|
187
|
-
|
|
188
|
-
|
|
456
|
+
name = meta.name,
|
|
457
|
+
description = meta.description or "",
|
|
458
|
+
timestamp = meta.timestamp or 0,
|
|
459
|
+
trackCount = meta.trackCount,
|
|
460
|
+
path = path,
|
|
189
461
|
}
|
|
190
462
|
end
|
|
191
463
|
end
|
|
192
464
|
end
|
|
193
465
|
i = i + 1
|
|
194
466
|
end
|
|
195
|
-
|
|
467
|
+
-- Newest first, with a stable tiebreak (timestamps are 1-second granular, so
|
|
468
|
+
-- before/after pairs saved in the same second must not reorder randomly).
|
|
469
|
+
table.sort(result, function(a, b)
|
|
470
|
+
if a.timestamp == b.timestamp then return a.name < b.name end
|
|
471
|
+
return a.timestamp > b.timestamp
|
|
472
|
+
end)
|
|
196
473
|
return result
|
|
197
474
|
end
|
|
198
475
|
|
|
199
476
|
-- =============================================================================
|
|
200
|
-
--
|
|
477
|
+
-- Capture mixer state
|
|
201
478
|
-- =============================================================================
|
|
202
479
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
480
|
+
-- opts.fullChunk (default TRUE) — capture each track's exact state chunk
|
|
481
|
+
-- (GetTrackStateChunk), which faithfully reproduces every plugin's internal
|
|
482
|
+
-- state including Helix Native's internal SNAPSHOT selection. A param-VALUE
|
|
483
|
+
-- capture cannot do this: the Helix snapshot index lives in the opaque VST
|
|
484
|
+
-- chunk, not in the host-visible automatable parameter list, so a param-only
|
|
485
|
+
-- restore lands Helix on its default (clean) snapshot. This is the same fix
|
|
486
|
+
-- the MCP bridge uses; the action scripts share this lib so all of them
|
|
487
|
+
-- (quick-save / next / prev / manager) get faithful capture at once.
|
|
488
|
+
-- Set fullChunk=false for the legacy lightweight param-value capture.
|
|
489
|
+
function M.capture_mixer_state(opts)
|
|
490
|
+
opts = opts or {}
|
|
491
|
+
local full_chunk = opts.fullChunk ~= false -- default true
|
|
492
|
+
local state = { version = 2, fullChunk = full_chunk and true or false, tracks = {} }
|
|
493
|
+
local count = reaper.CountTracks(0)
|
|
494
|
+
for i = 0, count - 1 do
|
|
495
|
+
local track = reaper.GetTrack(0, i)
|
|
496
|
+
local _, name = reaper.GetTrackName(track)
|
|
497
|
+
local vol = reaper.GetMediaTrackInfo_Value(track, "D_VOL")
|
|
498
|
+
local pan = reaper.GetMediaTrackInfo_Value(track, "D_PAN")
|
|
499
|
+
local mute = reaper.GetMediaTrackInfo_Value(track, "B_MUTE")
|
|
500
|
+
local solo = reaper.GetMediaTrackInfo_Value(track, "I_SOLO")
|
|
501
|
+
local color = reaper.GetMediaTrackInfo_Value(track, "I_CUSTOMCOLOR")
|
|
209
502
|
|
|
210
|
-
|
|
211
|
-
|
|
503
|
+
local track_entry = {
|
|
504
|
+
index = i, name = name, color = color,
|
|
505
|
+
volume = vol, pan = pan,
|
|
506
|
+
mute = mute ~= 0, solo = solo ~= 0,
|
|
507
|
+
}
|
|
212
508
|
|
|
213
|
-
|
|
214
|
-
|
|
509
|
+
if full_chunk then
|
|
510
|
+
-- Exact, complete track state: FX chain + each plugin's internal VST state
|
|
511
|
+
-- (incl. Helix snapshot selection), routing, envelopes. Base64 so the
|
|
512
|
+
-- chunk's quotes/newlines/{GUID} braces survive the JSON round-trip.
|
|
513
|
+
local ok, chunk = reaper.GetTrackStateChunk(track, "", false)
|
|
514
|
+
if ok and chunk and chunk ~= "" then
|
|
515
|
+
track_entry.chunkB64 = M.base64_encode(chunk)
|
|
516
|
+
end
|
|
517
|
+
else
|
|
518
|
+
-- Legacy lightweight capture: FX bypass/offline + param values + sends.
|
|
519
|
+
local fx_count = reaper.TrackFX_GetCount(track)
|
|
520
|
+
local fx_states = {}
|
|
521
|
+
for j = 0, fx_count - 1 do
|
|
522
|
+
local enabled = reaper.TrackFX_GetEnabled(track, j)
|
|
523
|
+
local offline = reaper.TrackFX_GetOffline(track, j)
|
|
524
|
+
local _, preset = reaper.TrackFX_GetPreset(track, j)
|
|
525
|
+
local _, fx_name = reaper.TrackFX_GetFXName(track, j)
|
|
526
|
+
local params_data = {}
|
|
527
|
+
local param_count = reaper.TrackFX_GetNumParams(track, j)
|
|
528
|
+
local limit = math.min(param_count, 500)
|
|
529
|
+
for p = 0, limit - 1 do
|
|
530
|
+
params_data[p + 1] = reaper.TrackFX_GetParam(track, j, p)
|
|
531
|
+
end
|
|
532
|
+
fx_states[j + 1] = {
|
|
533
|
+
name = fx_name, enabled = enabled, offline = offline,
|
|
534
|
+
preset = preset or "", params = params_data,
|
|
535
|
+
}
|
|
536
|
+
end
|
|
215
537
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
538
|
+
local send_count = reaper.GetTrackNumSends(track, 0)
|
|
539
|
+
local sends = {}
|
|
540
|
+
for s = 0, send_count - 1 do
|
|
541
|
+
local dest_track = reaper.GetTrackSendInfo_Value(track, 0, s, "P_DESTTRACK")
|
|
542
|
+
local dest_idx = -1
|
|
543
|
+
local dest_name = ""
|
|
544
|
+
if dest_track then
|
|
545
|
+
dest_idx = reaper.GetMediaTrackInfo_Value(dest_track, "IP_TRACKNUMBER") - 1
|
|
546
|
+
local _, dname = reaper.GetTrackName(dest_track)
|
|
547
|
+
dest_name = dname or ""
|
|
548
|
+
end
|
|
549
|
+
sends[s + 1] = {
|
|
550
|
+
destTrackIndex = dest_idx, destTrackName = dest_name,
|
|
551
|
+
volume = reaper.GetTrackSendInfo_Value(track, 0, s, "D_VOL"),
|
|
552
|
+
pan = reaper.GetTrackSendInfo_Value(track, 0, s, "D_PAN"),
|
|
553
|
+
muted = reaper.GetTrackSendInfo_Value(track, 0, s, "B_MUTE") ~= 0,
|
|
554
|
+
}
|
|
230
555
|
end
|
|
231
|
-
|
|
232
|
-
|
|
556
|
+
|
|
557
|
+
local fx_enabled = {}
|
|
558
|
+
for j = 1, #fx_states do fx_enabled[j] = fx_states[j].enabled end
|
|
559
|
+
|
|
560
|
+
track_entry.fx = fx_states
|
|
561
|
+
track_entry.sends = sends
|
|
562
|
+
track_entry.fxEnabled = fx_enabled
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
state.tracks[i + 1] = track_entry
|
|
233
566
|
end
|
|
567
|
+
return state
|
|
568
|
+
end
|
|
234
569
|
|
|
235
|
-
|
|
570
|
+
-- =============================================================================
|
|
571
|
+
-- Save
|
|
572
|
+
-- =============================================================================
|
|
573
|
+
|
|
574
|
+
function M.snapshot_exists(name)
|
|
575
|
+
local f = io.open(M.snapshot_path(name), "rb")
|
|
576
|
+
if f then f:close(); return true end
|
|
577
|
+
return false
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
-- The name a save without one gets: the local date and time (the status page's timeSnapshotName()
|
|
581
|
+
-- matches), with " 2", " 3"… when a snapshot already saves to that file.
|
|
582
|
+
function M.time_name(t)
|
|
583
|
+
local base = os.date("%Y-%m-%d %H:%M:%S", t)
|
|
584
|
+
local name, n = base, 1
|
|
585
|
+
while M.snapshot_exists(name) do
|
|
586
|
+
n = n + 1
|
|
587
|
+
name = base .. " " .. n
|
|
588
|
+
end
|
|
589
|
+
return name
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
-- Captures and writes a snapshot. Metadata is written BEFORE mixerState so the
|
|
593
|
+
-- list can read name/timestamp/trackCount from a cheap, string-safe prefix.
|
|
594
|
+
-- Returns: ok, err, trackCount, path
|
|
595
|
+
function M.save_snapshot(name, description)
|
|
596
|
+
if not name or name == "" then return false, "name required" end
|
|
597
|
+
M.ensure_snapshot_dir()
|
|
598
|
+
local state = M.capture_mixer_state()
|
|
599
|
+
local ts = os.time() * 1000
|
|
600
|
+
local content = "{"
|
|
601
|
+
.. '"name":' .. json_encode_string(name) .. ","
|
|
602
|
+
.. '"description":' .. json_encode_string(description or "") .. ","
|
|
603
|
+
.. '"timestamp":' .. tostring(ts) .. ","
|
|
604
|
+
.. '"trackCount":' .. tostring(#state.tracks) .. ","
|
|
605
|
+
.. '"mixerState":' .. M.json_encode(state)
|
|
606
|
+
.. "}"
|
|
607
|
+
local path = M.snapshot_path(name)
|
|
608
|
+
if not M.write_file_atomic(path, content) then
|
|
609
|
+
return false, "Failed to write: " .. path
|
|
610
|
+
end
|
|
611
|
+
return true, nil, #state.tracks, path
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
-- =============================================================================
|
|
615
|
+
-- Restore (FULL v2 restore — parity with the MCP bridge)
|
|
616
|
+
-- =============================================================================
|
|
617
|
+
-- opts.restoreTrackMeta (default false) — also restore track name + color
|
|
618
|
+
-- opts.restoreSendLevels (default true) — restore send volume/pan/mute
|
|
619
|
+
-- Returns: ok, err, tracksRestored, warnings
|
|
620
|
+
function M.restore_snapshot(snap, opts)
|
|
621
|
+
opts = opts or {}
|
|
622
|
+
local restore_meta = opts.restoreTrackMeta == true
|
|
623
|
+
local restore_sends = opts.restoreSendLevels ~= false
|
|
624
|
+
|
|
625
|
+
local content = M.read_file(snap.path)
|
|
626
|
+
if not content then return false, "File not found" end
|
|
627
|
+
|
|
628
|
+
local data = M.json_decode(content)
|
|
629
|
+
if type(data) ~= "table" or type(data.mixerState) ~= "table" then
|
|
630
|
+
return false, "Could not parse snapshot"
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
local state = data.mixerState
|
|
634
|
+
local version = tonumber(state.version) or 1
|
|
635
|
+
local tracks = state.tracks
|
|
636
|
+
if type(tracks) ~= "table" then return false, "No tracks in snapshot" end
|
|
637
|
+
|
|
638
|
+
local warnings = {}
|
|
639
|
+
local function truthy(v) return v == true or v == "true" or v == 1 end
|
|
236
640
|
|
|
237
641
|
reaper.Undo_BeginBlock()
|
|
238
642
|
local restored = 0
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if
|
|
242
|
-
local track = reaper.GetTrack(0, tonumber(
|
|
643
|
+
|
|
644
|
+
for _, ts in ipairs(tracks) do
|
|
645
|
+
if type(ts) == "table" and ts.index ~= nil then
|
|
646
|
+
local track = reaper.GetTrack(0, tonumber(ts.index))
|
|
243
647
|
if track then
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if
|
|
247
|
-
|
|
648
|
+
-- Track identity check: warn (don't block) if the track at this index
|
|
649
|
+
-- isn't the one that was saved — tracks may have been added/reordered.
|
|
650
|
+
if ts.name and ts.name ~= "" then
|
|
651
|
+
local _, cur = reaper.GetTrackName(track)
|
|
652
|
+
if cur and cur ~= ts.name then
|
|
653
|
+
warnings[#warnings + 1] = "Track " .. tostring(ts.index)
|
|
654
|
+
.. ": is now '" .. cur .. "' (saved as '" .. ts.name .. "')"
|
|
655
|
+
end
|
|
248
656
|
end
|
|
249
|
-
|
|
250
|
-
|
|
657
|
+
|
|
658
|
+
if ts.chunkB64 then
|
|
659
|
+
-- Exact restore: replace the entire track state from the saved chunk.
|
|
660
|
+
-- This faithfully reproduces each plugin's internal state (incl. Helix
|
|
661
|
+
-- Native snapshot selection) which a parameter-value restore cannot.
|
|
662
|
+
-- The chunk is this track's own state only, so sibling/child tracks
|
|
663
|
+
-- (e.g. guitars under a folder) are untouched.
|
|
664
|
+
if reaper.SetTrackStateChunk(track, M.base64_decode(ts.chunkB64), false) then
|
|
665
|
+
restored = restored + 1
|
|
666
|
+
else
|
|
667
|
+
warnings[#warnings + 1] = "Track " .. tostring(ts.index) .. ": SetTrackStateChunk failed"
|
|
668
|
+
end
|
|
669
|
+
else
|
|
670
|
+
-- Basic mixer state (all versions)
|
|
671
|
+
if ts.volume ~= nil then reaper.SetMediaTrackInfo_Value(track, "D_VOL", tonumber(ts.volume)) end
|
|
672
|
+
if ts.pan ~= nil then reaper.SetMediaTrackInfo_Value(track, "D_PAN", tonumber(ts.pan)) end
|
|
673
|
+
if ts.mute ~= nil then reaper.SetMediaTrackInfo_Value(track, "B_MUTE", truthy(ts.mute) and 1 or 0) end
|
|
674
|
+
if ts.solo ~= nil then reaper.SetMediaTrackInfo_Value(track, "I_SOLO", truthy(ts.solo) and 1 or 0) end
|
|
675
|
+
|
|
676
|
+
-- Track metadata (v2, opt-in)
|
|
677
|
+
if restore_meta and version >= 2 then
|
|
678
|
+
if ts.name then reaper.GetSetMediaTrackInfo_String(track, "P_NAME", ts.name, true) end
|
|
679
|
+
local col = tonumber(ts.color)
|
|
680
|
+
if col and col ~= 0 then reaper.SetMediaTrackInfo_Value(track, "I_CUSTOMCOLOR", col) end
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
-- FX state (v2: enabled/offline + params; v1: bypass only)
|
|
684
|
+
if version >= 2 and type(ts.fx) == "table" then
|
|
685
|
+
for j, fx in ipairs(ts.fx) do
|
|
686
|
+
local fx_idx = j - 1
|
|
687
|
+
if fx_idx < reaper.TrackFX_GetCount(track) then
|
|
688
|
+
if fx.enabled ~= nil then reaper.TrackFX_SetEnabled(track, fx_idx, truthy(fx.enabled)) end
|
|
689
|
+
if fx.offline ~= nil then reaper.TrackFX_SetOffline(track, fx_idx, truthy(fx.offline)) end
|
|
690
|
+
-- Restore params only if the plugin name matches (avoid applying
|
|
691
|
+
-- wrong values to a different plugin at the same chain index).
|
|
692
|
+
if type(fx.params) == "table" and #fx.params > 0 then
|
|
693
|
+
local _, cur_name = reaper.TrackFX_GetFXName(track, fx_idx)
|
|
694
|
+
if cur_name == fx.name then
|
|
695
|
+
for p, val in ipairs(fx.params) do
|
|
696
|
+
reaper.TrackFX_SetParam(track, fx_idx, p - 1, tonumber(val) or 0)
|
|
697
|
+
end
|
|
698
|
+
else
|
|
699
|
+
warnings[#warnings + 1] = "Track " .. tostring(ts.index) .. " FX " .. fx_idx
|
|
700
|
+
.. ": plugin changed, params skipped"
|
|
701
|
+
end
|
|
702
|
+
end
|
|
703
|
+
end
|
|
704
|
+
end
|
|
705
|
+
elseif type(ts.fxEnabled) == "table" then
|
|
706
|
+
for j, en in ipairs(ts.fxEnabled) do
|
|
707
|
+
local fx_idx = j - 1
|
|
708
|
+
if fx_idx < reaper.TrackFX_GetCount(track) then
|
|
709
|
+
reaper.TrackFX_SetEnabled(track, fx_idx, truthy(en))
|
|
710
|
+
end
|
|
711
|
+
end
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
-- Send levels (v2, default on) — matched by destination track index
|
|
715
|
+
if version >= 2 and restore_sends and type(ts.sends) == "table" then
|
|
716
|
+
local send_count = reaper.GetTrackNumSends(track, 0)
|
|
717
|
+
for _, sv in ipairs(ts.sends) do
|
|
718
|
+
for s = 0, send_count - 1 do
|
|
719
|
+
local dest = reaper.GetTrackSendInfo_Value(track, 0, s, "P_DESTTRACK")
|
|
720
|
+
if dest then
|
|
721
|
+
local dest_idx = reaper.GetMediaTrackInfo_Value(dest, "IP_TRACKNUMBER") - 1
|
|
722
|
+
if dest_idx == tonumber(sv.destTrackIndex) then
|
|
723
|
+
reaper.SetTrackSendInfo_Value(track, 0, s, "D_VOL", tonumber(sv.volume) or 1)
|
|
724
|
+
reaper.SetTrackSendInfo_Value(track, 0, s, "D_PAN", tonumber(sv.pan) or 0)
|
|
725
|
+
reaper.SetTrackSendInfo_Value(track, 0, s, "B_MUTE", truthy(sv.muted) and 1 or 0)
|
|
726
|
+
break
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
end
|
|
251
731
|
end
|
|
732
|
+
|
|
252
733
|
restored = restored + 1
|
|
734
|
+
end -- close: chunkB64 vs legacy param-value restore
|
|
253
735
|
end
|
|
254
736
|
end
|
|
255
737
|
end
|
|
738
|
+
|
|
256
739
|
reaper.Undo_EndBlock("MCP Snapshot: Restore '" .. snap.name .. "'", -1)
|
|
257
740
|
reaper.TrackList_AdjustWindows(false)
|
|
258
741
|
reaper.UpdateArrange()
|
|
259
|
-
return true, nil, restored
|
|
742
|
+
return true, nil, restored, warnings
|
|
260
743
|
end
|
|
261
744
|
|
|
262
745
|
-- =============================================================================
|
|
263
|
-
--
|
|
746
|
+
-- Delete
|
|
264
747
|
-- =============================================================================
|
|
265
748
|
|
|
266
|
-
function M.
|
|
267
|
-
local
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
local track = reaper.GetTrack(0, i)
|
|
271
|
-
local _, name = reaper.GetTrackName(track)
|
|
272
|
-
local vol = reaper.GetMediaTrackInfo_Value(track, "D_VOL")
|
|
273
|
-
local pan = reaper.GetMediaTrackInfo_Value(track, "D_PAN")
|
|
274
|
-
local mute = reaper.GetMediaTrackInfo_Value(track, "B_MUTE")
|
|
275
|
-
local solo = reaper.GetMediaTrackInfo_Value(track, "I_SOLO")
|
|
276
|
-
local color = reaper.GetMediaTrackInfo_Value(track, "I_CUSTOMCOLOR")
|
|
277
|
-
|
|
278
|
-
local fx_count = reaper.TrackFX_GetCount(track)
|
|
279
|
-
local fx_states = {}
|
|
280
|
-
for j = 0, fx_count - 1 do
|
|
281
|
-
local enabled = reaper.TrackFX_GetEnabled(track, j)
|
|
282
|
-
local offline = reaper.TrackFX_GetOffline(track, j)
|
|
283
|
-
local _, preset = reaper.TrackFX_GetPreset(track, j)
|
|
284
|
-
local _, fx_name = reaper.TrackFX_GetFXName(track, j)
|
|
285
|
-
local params_data = {}
|
|
286
|
-
local param_count = reaper.TrackFX_GetNumParams(track, j)
|
|
287
|
-
local limit = math.min(param_count, 500)
|
|
288
|
-
for p = 0, limit - 1 do
|
|
289
|
-
params_data[p + 1] = reaper.TrackFX_GetParam(track, j, p)
|
|
290
|
-
end
|
|
291
|
-
fx_states[j + 1] = {
|
|
292
|
-
name = fx_name, enabled = enabled, offline = offline,
|
|
293
|
-
preset = preset or "", params = params_data,
|
|
294
|
-
}
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
local send_count = reaper.GetTrackNumSends(track, 0)
|
|
298
|
-
local sends = {}
|
|
299
|
-
for s = 0, send_count - 1 do
|
|
300
|
-
local dest_track = reaper.GetTrackSendInfo_Value(track, 0, s, "P_DESTTRACK")
|
|
301
|
-
local dest_idx = -1
|
|
302
|
-
local dest_name = ""
|
|
303
|
-
if dest_track then
|
|
304
|
-
dest_idx = reaper.GetMediaTrackInfo_Value(dest_track, "IP_TRACKNUMBER") - 1
|
|
305
|
-
local _, dname = reaper.GetTrackName(dest_track)
|
|
306
|
-
dest_name = dname or ""
|
|
307
|
-
end
|
|
308
|
-
sends[s + 1] = {
|
|
309
|
-
destTrackIndex = dest_idx, destTrackName = dest_name,
|
|
310
|
-
volume = reaper.GetTrackSendInfo_Value(track, 0, s, "D_VOL"),
|
|
311
|
-
pan = reaper.GetTrackSendInfo_Value(track, 0, s, "D_PAN"),
|
|
312
|
-
muted = reaper.GetTrackSendInfo_Value(track, 0, s, "B_MUTE") ~= 0,
|
|
313
|
-
}
|
|
314
|
-
end
|
|
315
|
-
|
|
316
|
-
local fx_enabled = {}
|
|
317
|
-
for j = 1, #fx_states do fx_enabled[j] = fx_states[j].enabled end
|
|
318
|
-
|
|
319
|
-
state.tracks[i + 1] = {
|
|
320
|
-
index = i, name = name, color = color,
|
|
321
|
-
volume = vol, pan = pan,
|
|
322
|
-
mute = mute ~= 0, solo = solo ~= 0,
|
|
323
|
-
fx = fx_states, sends = sends, fxEnabled = fx_enabled,
|
|
324
|
-
}
|
|
325
|
-
end
|
|
326
|
-
return state
|
|
749
|
+
function M.delete_snapshot(snap)
|
|
750
|
+
local ok, err = os.remove(snap.path)
|
|
751
|
+
if not ok then return false, err or "unknown error" end
|
|
752
|
+
return true
|
|
327
753
|
end
|
|
328
754
|
|
|
329
755
|
-- =============================================================================
|
|
330
|
-
-- Persisted
|
|
756
|
+
-- Persisted current-snapshot (by name) for A/B next/prev
|
|
331
757
|
-- =============================================================================
|
|
332
758
|
|
|
333
|
-
function M.
|
|
334
|
-
local
|
|
335
|
-
return
|
|
759
|
+
function M.get_current_name()
|
|
760
|
+
local v = reaper.GetExtState(M.EXT_SECTION, M.EXT_KEY_NAME)
|
|
761
|
+
if v == "" then return nil end
|
|
762
|
+
return v
|
|
336
763
|
end
|
|
337
764
|
|
|
338
|
-
function M.
|
|
339
|
-
reaper.SetExtState(M.EXT_SECTION, M.
|
|
765
|
+
function M.set_current_name(name)
|
|
766
|
+
reaper.SetExtState(M.EXT_SECTION, M.EXT_KEY_NAME, name or "", false)
|
|
340
767
|
end
|
|
341
768
|
|
|
342
769
|
-- =============================================================================
|
|
343
|
-
-- Toast
|
|
770
|
+
-- Toast (brief feedback in the REAPER help bar)
|
|
344
771
|
-- =============================================================================
|
|
345
772
|
|
|
346
773
|
function M.toast(msg)
|