@mthines/reaper-mcp 0.19.0 → 0.70.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +19 -570
  2. package/bridge-ui/mcp_bridge.html +129 -0
  3. package/bridge-ui/mcp_bridge.webmanifest +16 -0
  4. package/bridge-ui/mcp_bridge_icon_192.png +0 -0
  5. package/bridge-ui/mcp_bridge_icon_512.png +0 -0
  6. package/bridge-ui/mcp_bridge_sw.js +35 -0
  7. package/claude/agents/plugin-researcher.md +188 -0
  8. package/claude/knowledge/reference/aesthetics-scoring.md +48 -0
  9. package/{knowledge → claude/knowledge}/reference/common-mistakes.md +1 -1
  10. package/claude/knowledge/reference/lorekit-setup.md +36 -0
  11. package/claude/knowledge/reference/memory-protocol.md +377 -0
  12. package/{knowledge → claude/knowledge}/reference/metering.md +5 -5
  13. package/claude/knowledge/reference/perceived-loudness.md +182 -0
  14. package/{knowledge → claude/knowledge}/workflows/editing.md +4 -0
  15. package/{knowledge → claude/knowledge}/workflows/gain-staging.md +21 -23
  16. package/{knowledge → claude/knowledge}/workflows/learn-plugin.md +15 -48
  17. package/{knowledge → claude/knowledge}/workflows/master-bus.md +13 -1
  18. package/claude/knowledge/workflows/section-levels.md +138 -0
  19. package/{knowledge → claude/knowledge}/workflows/session-prep.md +4 -0
  20. package/claude/knowledge/workflows/transient-lighting.md +52 -0
  21. package/claude/skills/critique/SKILL.md +131 -0
  22. package/claude/skills/editing/SKILL.md +281 -0
  23. package/claude/skills/learn-plugin/SKILL.md +210 -0
  24. package/claude/skills/mastering/SKILL.md +151 -0
  25. package/claude/skills/mix-memory/SKILL.md +140 -0
  26. package/claude/skills/mixer/SKILL.md +253 -0
  27. package/claude/skills/new-extension/SKILL.md +324 -0
  28. package/main.js +7843 -1940
  29. package/package.json +17 -15
  30. package/reaper/CLAUDE.md +22 -4
  31. package/reaper/mcp_actions_lib.lua +375 -0
  32. package/reaper/mcp_analyzer.jsfx +27 -12
  33. package/reaper/mcp_arrange_lib.lua +949 -0
  34. package/reaper/mcp_bridge.lua +2321 -758
  35. package/reaper/mcp_edit_views_lib.lua +1378 -0
  36. package/reaper/mcp_envelopes_lib.lua +748 -0
  37. package/reaper/mcp_extensions_lib.lua +55 -0
  38. package/reaper/mcp_fxstate_lib.lua +144 -0
  39. package/reaper/mcp_gain_trace.jsfx +126 -0
  40. package/reaper/mcp_items_lib.lua +877 -0
  41. package/reaper/mcp_lufs_meter.jsfx +16 -13
  42. package/reaper/mcp_midi_lib.lua +555 -0
  43. package/reaper/mcp_mixing_lib.lua +1021 -0
  44. package/reaper/mcp_plan_lib.lua +801 -0
  45. package/reaper/mcp_prefs_lib.lua +127 -0
  46. package/reaper/mcp_project_lib.lua +1088 -0
  47. package/reaper/mcp_recording_lib.lua +595 -0
  48. package/reaper/mcp_render_lib.lua +492 -0
  49. package/reaper/mcp_snapshot_lib.lua +547 -120
  50. package/reaper/mcp_snapshot_next.lua +10 -4
  51. package/reaper/mcp_snapshot_prev.lua +11 -4
  52. package/reaper/mcp_snapshot_quick_save.lua +7 -32
  53. package/reaper/mcp_snapshots_ui_lib.lua +258 -0
  54. package/reaper/mcp_tracks_lib.lua +409 -0
  55. package/sidecar/analysis.py +661 -0
  56. package/sidecar/chords.py +96 -0
  57. package/sidecar/mixcheck.py +295 -0
  58. package/sidecar/requirements-chords.txt +6 -0
  59. package/sidecar/requirements-separation.txt +3 -0
  60. package/sidecar/requirements.txt +17 -0
  61. package/sidecar/separation.py +142 -0
  62. package/sidecar/server.py +622 -0
  63. package/sidecar/tests/test_analysis.py +249 -0
  64. package/sidecar/tests/test_chords.py +117 -0
  65. package/sidecar/tests/test_mixcheck.py +163 -0
  66. package/sidecar/tests/test_separation.py +73 -0
  67. package/sidecar/tests/test_server.py +464 -0
  68. package/sidecar/tests/test_verify.py +243 -0
  69. package/sidecar/tests/test_voice_render.py +268 -0
  70. package/sidecar/tests/test_voice_sketch.py +140 -0
  71. package/sidecar/verify.py +224 -0
  72. package/sidecar/voice_render.py +295 -0
  73. package/sidecar/voice_sketch.py +280 -0
  74. package/claude-agents/critique.md +0 -147
  75. package/claude-agents/editor.md +0 -184
  76. package/claude-agents/levels.md +0 -107
  77. package/claude-agents/mastering.md +0 -129
  78. package/claude-agents/mixer.md +0 -248
  79. package/claude-agents/preflight.md +0 -149
  80. package/claude-agents/producer.md +0 -159
  81. package/claude-agents/setup.md +0 -150
  82. package/claude-agents/stems.md +0 -135
  83. package/claude-rules/architecture.md +0 -39
  84. package/claude-rules/development.md +0 -54
  85. package/claude-rules/lua-bridge.md +0 -50
  86. package/claude-rules/testing.md +0 -42
  87. package/claude-skills/learn-plugin.md +0 -123
  88. package/knowledge/CLAUDE.md +0 -71
  89. package/knowledge/plugins/_template.md +0 -82
  90. package/knowledge/plugins/fabfilter/pro-c-2.md +0 -117
  91. package/knowledge/plugins/fabfilter/pro-l-2.md +0 -95
  92. package/knowledge/plugins/fabfilter/pro-q-3.md +0 -112
  93. package/knowledge/plugins/neural-dsp/helix-native.md +0 -104
  94. package/knowledge/plugins/stock-reaper/js-1175-compressor.md +0 -94
  95. package/knowledge/plugins/stock-reaper/rea-comp.md +0 -100
  96. package/knowledge/plugins/stock-reaper/rea-delay.md +0 -95
  97. package/knowledge/plugins/stock-reaper/rea-eq.md +0 -103
  98. package/knowledge/plugins/stock-reaper/rea-gate.md +0 -99
  99. package/knowledge/plugins/stock-reaper/rea-limit.md +0 -75
  100. package/knowledge/plugins/stock-reaper/rea-verb.md +0 -76
  101. package/knowledge/reference/perceived-loudness.md +0 -122
  102. package/reaper/mcp_snapshot_manager.lua +0 -1001
  103. /package/{knowledge → claude/knowledge}/genres/_template.md +0 -0
  104. /package/{knowledge → claude/knowledge}/genres/electronic.md +0 -0
  105. /package/{knowledge → claude/knowledge}/genres/hip-hop.md +0 -0
  106. /package/{knowledge → claude/knowledge}/genres/metal.md +0 -0
  107. /package/{knowledge → claude/knowledge}/genres/orchestral.md +0 -0
  108. /package/{knowledge → claude/knowledge}/genres/pop.md +0 -0
  109. /package/{knowledge → claude/knowledge}/genres/rock.md +0 -0
  110. /package/{knowledge → claude/knowledge}/reference/compression.md +0 -0
  111. /package/{knowledge → claude/knowledge}/reference/frequencies.md +0 -0
  112. /package/{knowledge → claude/knowledge}/workflows/delivery.md +0 -0
  113. /package/{knowledge → claude/knowledge}/workflows/drum-bus.md +0 -0
  114. /package/{knowledge → claude/knowledge}/workflows/low-end.md +0 -0
  115. /package/{knowledge → claude/knowledge}/workflows/stem-prep.md +0 -0
  116. /package/{knowledge → claude/knowledge}/workflows/vocal-chain.md +0 -0
@@ -1,15 +1,22 @@
1
1
  -- =============================================================================
2
2
  -- MCP Snapshot Library — shared helpers for snapshot action scripts
3
3
  -- =============================================================================
4
- -- Loaded via dofile() by snapshot action scripts. Provides path resolution,
5
- -- JSON parsing, snapshot listing, and restore logic.
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 index across action calls
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.EXT_KEY_IDX = "current_index"
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
- -- Minimal JSON helpers
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 ok, snap = pcall(M.parse_flat_object, content)
184
- if ok and snap and snap.name then
453
+ local meta = read_snapshot_meta(content)
454
+ if meta and meta.name then
185
455
  result[#result + 1] = {
186
- name = snap.name,
187
- timestamp = tonumber(snap.timestamp) or 0,
188
- path = path,
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
- table.sort(result, function(a, b) return a.timestamp > b.timestamp end)
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
- -- Restore logic
477
+ -- Capture mixer state
201
478
  -- =============================================================================
202
479
 
203
- function M.restore_snapshot(snap)
204
- local content = M.read_file(snap.path)
205
- if not content then return false, "File not found" end
206
-
207
- local mixer_block_start = content:find('"mixerState"%s*:%s*{')
208
- if not mixer_block_start then return false, "No mixerState" end
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
- local tracks_start = content:find('"tracks"%s*:%s*%[', mixer_block_start)
211
- if not tracks_start then return false, "No tracks" end
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
- local arr_open = content:find('%[', tracks_start)
214
- if not arr_open then return false, "Invalid format" end
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
- local track_objects = {}
217
- local depth = 0
218
- local obj_start = nil
219
- local i = arr_open
220
- while i <= #content do
221
- local ch = content:sub(i, i)
222
- if ch == '{' then
223
- depth = depth + 1
224
- if depth == 1 then obj_start = i end
225
- elseif ch == '}' then
226
- depth = depth - 1
227
- if depth == 0 and obj_start then
228
- track_objects[#track_objects + 1] = content:sub(obj_start, i)
229
- obj_start = nil
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
- elseif ch == ']' and depth == 0 then break end
232
- i = i + 1
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
- if #track_objects == 0 then return false, "No track data" end
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
- for _, track_json in ipairs(track_objects) do
240
- local ok_t, td = pcall(M.parse_flat_object, track_json)
241
- if ok_t and td and td.index then
242
- local track = reaper.GetTrack(0, tonumber(td.index))
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
- if td.volume then reaper.SetMediaTrackInfo_Value(track, "D_VOL", tonumber(td.volume)) end
245
- if td.pan then reaper.SetMediaTrackInfo_Value(track, "D_PAN", tonumber(td.pan)) end
246
- if td.mute ~= nil then
247
- reaper.SetMediaTrackInfo_Value(track, "B_MUTE", (td.mute == true or td.mute == "true") and 1 or 0)
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
- if td.solo ~= nil then
250
- reaper.SetMediaTrackInfo_Value(track, "I_SOLO", (td.solo == true or td.solo == "true") and 1 or 0)
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
- -- Capture mixer state
746
+ -- Delete
264
747
  -- =============================================================================
265
748
 
266
- function M.capture_mixer_state()
267
- local state = { version = 2, tracks = {} }
268
- local count = reaper.CountTracks(0)
269
- for i = 0, count - 1 do
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 index helpers
756
+ -- Persisted current-snapshot (by name) for A/B next/prev
331
757
  -- =============================================================================
332
758
 
333
- function M.get_current_index()
334
- local val = reaper.GetExtState(M.EXT_SECTION, M.EXT_KEY_IDX)
335
- return tonumber(val) or 0
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.set_current_index(idx)
339
- reaper.SetExtState(M.EXT_SECTION, M.EXT_KEY_IDX, tostring(idx), false)
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 message (brief console feedback)
770
+ -- Toast (brief feedback in the REAPER help bar)
344
771
  -- =============================================================================
345
772
 
346
773
  function M.toast(msg)