@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,1001 +0,0 @@
1
- -- =============================================================================
2
- -- MCP Snapshot Manager for REAPER
3
- -- =============================================================================
4
- -- A standalone gfx.* UI for managing mixer snapshots created by the MCP server.
5
- -- Uses only built-in REAPER APIs (no ReaImGui, no SWS required).
6
- --
7
- -- Install: Actions > Show action list > Load ReaScript > select this file > Run
8
- -- =============================================================================
9
-
10
- local TITLE = "MCP Snapshot Manager"
11
- local WIN_W = 820
12
- local WIN_H = 520
13
- local PADDING = 10
14
- local ROW_H = 26
15
- local COL_HEADER_H = 28
16
- local BUTTON_H = 32
17
- local BUTTON_W = 120
18
- local FOOTER_H = BUTTON_H + PADDING * 2
19
- local STATUS_H = 22
20
- local LIST_TOP = COL_HEADER_H + PADDING
21
- local LIST_BOTTOM_MARGIN = FOOTER_H + STATUS_H + PADDING
22
- local SCROLL_BAR_W = 14
23
-
24
- -- Column widths
25
- local COL_NAME_W = 220
26
- local COL_DESC_W = 280
27
- local COL_DATE_W = 160
28
- local COL_TRACKS_W = 60
29
-
30
- -- Colors (R, G, B, A as 0-1 floats)
31
- local C_BG = { 0.13, 0.13, 0.15, 1.0 }
32
- local C_HEADER_BG = { 0.18, 0.18, 0.22, 1.0 }
33
- local C_ROW_EVEN = { 0.15, 0.15, 0.18, 1.0 }
34
- local C_ROW_ODD = { 0.17, 0.17, 0.20, 1.0 }
35
- local C_ROW_SEL = { 0.20, 0.38, 0.60, 1.0 }
36
- local C_ROW_HOVER = { 0.22, 0.22, 0.28, 1.0 }
37
- local C_TEXT = { 0.90, 0.90, 0.90, 1.0 }
38
- local C_TEXT_DIM = { 0.55, 0.55, 0.60, 1.0 }
39
- local C_TEXT_SEL = { 1.00, 1.00, 1.00, 1.0 }
40
- local C_TEXT_HEADER = { 0.75, 0.75, 0.80, 1.0 }
41
- local C_BTN_NORMAL = { 0.25, 0.25, 0.30, 1.0 }
42
- local C_BTN_HOVER = { 0.30, 0.45, 0.65, 1.0 }
43
- local C_BTN_PRESS = { 0.20, 0.35, 0.55, 1.0 }
44
- local C_BTN_DELETE = { 0.55, 0.20, 0.20, 1.0 }
45
- local C_BTN_DEL_HOV = { 0.70, 0.25, 0.25, 1.0 }
46
- local C_BTN_RESTORE = { 0.20, 0.50, 0.25, 1.0 }
47
- local C_BTN_RES_HOV = { 0.25, 0.62, 0.30, 1.0 }
48
- local C_DIVIDER = { 0.30, 0.30, 0.35, 1.0 }
49
- local C_SCROLLBAR = { 0.35, 0.35, 0.40, 1.0 }
50
- local C_SCROLLTHUMB = { 0.50, 0.50, 0.58, 1.0 }
51
- local C_STATUS_OK = { 0.35, 0.75, 0.40, 1.0 }
52
- local C_STATUS_ERR = { 0.85, 0.35, 0.35, 1.0 }
53
- local C_STATUS_INFO = { 0.65, 0.65, 0.70, 1.0 }
54
-
55
- -- =============================================================================
56
- -- State
57
- -- =============================================================================
58
-
59
- local snapshots = {} -- list of snapshot tables
60
- local selected_idx = 0 -- 1-based, 0 = none
61
- local scroll_offset = 0 -- rows scrolled from top
62
- local hover_row = 0 -- 1-based row under mouse, 0 = none
63
- local hover_btn = nil -- "save" | "restore" | "delete" | "refresh" | "close"
64
- local btn_pressed = nil
65
- local status_msg = ""
66
- local status_type = "info" -- "ok" | "err" | "info"
67
- local status_timer = 0
68
- local last_mouse_x = 0
69
- local last_mouse_y = 0
70
- local last_mouse_cap = 0
71
- local mouse_was_down = false
72
- local is_running = true
73
-
74
- -- =============================================================================
75
- -- Path helpers (duplicated from mcp_bridge.lua — standalone script)
76
- -- =============================================================================
77
-
78
- local function get_snapshot_dir()
79
- local proj_path = reaper.GetProjectPath()
80
- if proj_path and proj_path ~= "" then
81
- return proj_path .. "/.reaper-mcp/snapshots/"
82
- end
83
- -- Fallback for unsaved projects: use bridge data dir
84
- return reaper.GetResourcePath() .. "/Scripts/mcp_bridge_data/snapshots/"
85
- end
86
-
87
- local function ensure_snapshot_dir()
88
- reaper.RecursiveCreateDirectory(get_snapshot_dir(), 0)
89
- end
90
-
91
- local function snapshot_path(name)
92
- local safe = name:gsub("[^%w%-_%.%s]", "_"):gsub("%s+", "_")
93
- return get_snapshot_dir() .. safe .. ".json"
94
- end
95
-
96
- -- =============================================================================
97
- -- Minimal JSON helpers (enough for snapshot format)
98
- -- =============================================================================
99
-
100
- local function json_encode_string(s)
101
- s = tostring(s)
102
- s = s:gsub('\\', '\\\\')
103
- s = s:gsub('"', '\\"')
104
- s = s:gsub('\n', '\\n')
105
- s = s:gsub('\r', '\\r')
106
- s = s:gsub('\t', '\\t')
107
- return '"' .. s .. '"'
108
- end
109
-
110
- local function json_encode(val)
111
- local t = type(val)
112
- if t == "nil" then
113
- return "null"
114
- elseif t == "boolean" then
115
- return val and "true" or "false"
116
- elseif t == "number" then
117
- if val ~= val then return "null" end -- NaN
118
- return tostring(val)
119
- elseif t == "string" then
120
- return json_encode_string(val)
121
- elseif t == "table" then
122
- -- Check if array
123
- local is_array = true
124
- local max_i = 0
125
- for k, _ in pairs(val) do
126
- if type(k) ~= "number" or k ~= math.floor(k) or k < 1 then
127
- is_array = false
128
- break
129
- end
130
- if k > max_i then max_i = k end
131
- end
132
- if is_array and max_i == #val then
133
- local parts = {}
134
- for i = 1, #val do
135
- parts[i] = json_encode(val[i])
136
- end
137
- return "[" .. table.concat(parts, ",") .. "]"
138
- else
139
- local parts = {}
140
- for k, v in pairs(val) do
141
- parts[#parts + 1] = json_encode_string(tostring(k)) .. ":" .. json_encode(v)
142
- end
143
- return "{" .. table.concat(parts, ",") .. "}"
144
- end
145
- end
146
- return "null"
147
- end
148
-
149
- -- Extract a string value from JSON (handles escape sequences)
150
- local function extract_json_string(str, pos)
151
- local result = {}
152
- local i = pos
153
- while i <= #str do
154
- local ch = str:sub(i, i)
155
- if ch == '\\' then
156
- local next_ch = str:sub(i + 1, i + 1)
157
- if next_ch == '"' then result[#result + 1] = '"'
158
- elseif next_ch == '\\' then result[#result + 1] = '\\'
159
- elseif next_ch == 'n' then result[#result + 1] = '\n'
160
- elseif next_ch == 't' then result[#result + 1] = '\t'
161
- elseif next_ch == '/' then result[#result + 1] = '/'
162
- else result[#result + 1] = next_ch
163
- end
164
- i = i + 2
165
- elseif ch == '"' then
166
- return table.concat(result), i + 1
167
- else
168
- result[#result + 1] = ch
169
- i = i + 1
170
- end
171
- end
172
- return nil, i
173
- end
174
-
175
- -- Parse a flat JSON object (sufficient for top-level snapshot metadata)
176
- local function parse_flat_object(str)
177
- local obj = {}
178
- local i = 1
179
- while i <= #str do
180
- local key_start = str:find('"', i)
181
- if not key_start then break end
182
- local key, after_key = extract_json_string(str, key_start + 1)
183
- if not key then break end
184
- i = after_key
185
-
186
- local colon = str:find(':', i)
187
- if not colon then break end
188
- local val_start = str:match('^%s*()', colon + 1)
189
-
190
- local ch = str:sub(val_start, val_start)
191
- if ch == '"' then
192
- local val, next_pos = extract_json_string(str, val_start + 1)
193
- if val then
194
- obj[key] = val
195
- i = next_pos
196
- else
197
- i = val_start + 1
198
- end
199
- elseif ch == '{' or ch == '[' then
200
- -- Skip nested objects/arrays — not needed for top-level metadata
201
- local depth = 1
202
- local open_c = ch
203
- local close_c = ch == '{' and '}' or ']'
204
- local j = val_start + 1
205
- while j <= #str and depth > 0 do
206
- local c = str:sub(j, j)
207
- if c == open_c then depth = depth + 1
208
- elseif c == close_c then depth = depth - 1 end
209
- j = j + 1
210
- end
211
- -- Store raw (won't be used for top-level display)
212
- i = j
213
- else
214
- -- Number, boolean, null
215
- local val_end = str:find('[,}%]]', val_start)
216
- if not val_end then val_end = #str + 1 end
217
- local raw = str:sub(val_start, val_end - 1):match("^%s*(.-)%s*$")
218
- if raw == "true" then obj[key] = true
219
- elseif raw == "false" then obj[key] = false
220
- elseif raw == "null" then obj[key] = nil
221
- else obj[key] = tonumber(raw)
222
- end
223
- i = val_end
224
- end
225
- end
226
- return obj
227
- end
228
-
229
- -- Count tracks in mixerState.tracks array (rough count from string)
230
- local function count_tracks_in_snapshot(content)
231
- local mixer_start = content:find('"mixerState"')
232
- if not mixer_start then return nil end
233
- local tracks_start = content:find('"tracks"', mixer_start)
234
- if not tracks_start then return nil end
235
- local arr_start = content:find('%[', tracks_start)
236
- if not arr_start then return nil end
237
- -- Count top-level objects in the array
238
- local count = 0
239
- local depth = 0
240
- local i = arr_start
241
- while i <= math.min(#content, arr_start + 50000) do
242
- local ch = content:sub(i, i)
243
- if ch == '[' or ch == '{' then
244
- depth = depth + 1
245
- if ch == '{' and depth == 2 then count = count + 1 end
246
- elseif ch == ']' or ch == '}' then
247
- depth = depth - 1
248
- if depth == 0 then break end
249
- end
250
- i = i + 1
251
- end
252
- return count > 0 and count or nil
253
- end
254
-
255
- -- =============================================================================
256
- -- Snapshot I/O
257
- -- =============================================================================
258
-
259
- local function read_file(path)
260
- local f = io.open(path, "rb")
261
- if not f then return nil end
262
- local content = f:read("*a")
263
- f:close()
264
- return content
265
- end
266
-
267
- local function write_file(path, content)
268
- local f = io.open(path, "wb")
269
- if not f then return false end
270
- f:write(content)
271
- f:close()
272
- return true
273
- end
274
-
275
- local function load_snapshots()
276
- ensure_snapshot_dir()
277
- local snap_dir = get_snapshot_dir()
278
- local result = {}
279
-
280
- local i = 0
281
- while true do
282
- local fn = reaper.EnumerateFiles(snap_dir, i)
283
- if not fn then break end
284
- if fn:match("%.json$") then
285
- local path = snap_dir .. fn
286
- local content = read_file(path)
287
- if content then
288
- local ok, snap = pcall(parse_flat_object, content)
289
- if ok and snap and snap.name then
290
- -- Try to count tracks
291
- local track_count = nil
292
- pcall(function()
293
- track_count = count_tracks_in_snapshot(content)
294
- end)
295
- result[#result + 1] = {
296
- name = snap.name,
297
- description = snap.description or "",
298
- timestamp = tonumber(snap.timestamp) or 0,
299
- trackCount = track_count,
300
- path = path,
301
- }
302
- end
303
- end
304
- end
305
- i = i + 1
306
- end
307
-
308
- -- Sort by timestamp descending (newest first)
309
- table.sort(result, function(a, b) return a.timestamp > b.timestamp end)
310
- return result
311
- end
312
-
313
- local function format_timestamp(ts_ms)
314
- if not ts_ms or ts_ms == 0 then return "Unknown" end
315
- local ts = math.floor(ts_ms / 1000)
316
- return os.date("%Y-%m-%d %H:%M", ts)
317
- end
318
-
319
- -- =============================================================================
320
- -- Capture mixer state (standalone — duplicated from bridge for self-containment)
321
- -- =============================================================================
322
-
323
- local function capture_mixer_state()
324
- local state = { version = 2, tracks = {} }
325
- local count = reaper.CountTracks(0)
326
- for i = 0, count - 1 do
327
- local track = reaper.GetTrack(0, i)
328
- local _, name = reaper.GetTrackName(track)
329
- local vol = reaper.GetMediaTrackInfo_Value(track, "D_VOL")
330
- local pan = reaper.GetMediaTrackInfo_Value(track, "D_PAN")
331
- local mute = reaper.GetMediaTrackInfo_Value(track, "B_MUTE")
332
- local solo = reaper.GetMediaTrackInfo_Value(track, "I_SOLO")
333
- local color = reaper.GetMediaTrackInfo_Value(track, "I_CUSTOMCOLOR")
334
-
335
- local fx_count = reaper.TrackFX_GetCount(track)
336
- local fx_states = {}
337
- for j = 0, fx_count - 1 do
338
- local enabled = reaper.TrackFX_GetEnabled(track, j)
339
- local offline = reaper.TrackFX_GetOffline(track, j)
340
- local _, preset = reaper.TrackFX_GetPreset(track, j)
341
- local _, fx_name = reaper.TrackFX_GetFXName(track, j)
342
- local params_data = {}
343
- local param_count = reaper.TrackFX_GetNumParams(track, j)
344
- local limit = math.min(param_count, 500)
345
- for p = 0, limit - 1 do
346
- params_data[p + 1] = reaper.TrackFX_GetParam(track, j, p)
347
- end
348
- fx_states[j + 1] = {
349
- name = fx_name, enabled = enabled, offline = offline,
350
- preset = preset or "", params = params_data,
351
- }
352
- end
353
-
354
- local send_count = reaper.GetTrackNumSends(track, 0)
355
- local sends = {}
356
- for s = 0, send_count - 1 do
357
- local dest_track = reaper.GetTrackSendInfo_Value(track, 0, s, "P_DESTTRACK")
358
- local dest_idx = -1
359
- local dest_name = ""
360
- if dest_track then
361
- dest_idx = reaper.GetMediaTrackInfo_Value(dest_track, "IP_TRACKNUMBER") - 1
362
- local _, dname = reaper.GetTrackName(dest_track)
363
- dest_name = dname or ""
364
- end
365
- sends[s + 1] = {
366
- destTrackIndex = dest_idx, destTrackName = dest_name,
367
- volume = reaper.GetTrackSendInfo_Value(track, 0, s, "D_VOL"),
368
- pan = reaper.GetTrackSendInfo_Value(track, 0, s, "D_PAN"),
369
- muted = reaper.GetTrackSendInfo_Value(track, 0, s, "B_MUTE") ~= 0,
370
- }
371
- end
372
-
373
- local fx_enabled = {}
374
- for j = 1, #fx_states do fx_enabled[j] = fx_states[j].enabled end
375
-
376
- state.tracks[i + 1] = {
377
- index = i, name = name, color = color,
378
- volume = vol, pan = pan,
379
- mute = mute ~= 0, solo = solo ~= 0,
380
- fx = fx_states, sends = sends, fxEnabled = fx_enabled,
381
- }
382
- end
383
- return state
384
- end
385
-
386
- local function do_save_snapshot(name, description)
387
- ensure_snapshot_dir()
388
- local timestamp = os.time() * 1000
389
- local snapshot = {
390
- name = name,
391
- description = description or "",
392
- timestamp = timestamp,
393
- mixerState = capture_mixer_state(),
394
- }
395
- local path = snapshot_path(name)
396
- local ok = write_file(path, json_encode(snapshot))
397
- if not ok then
398
- return false, "Failed to write file: " .. path
399
- end
400
- return true, nil
401
- end
402
-
403
- local function do_restore_snapshot(snap)
404
- local content = read_file(snap.path)
405
- if not content then
406
- return false, "File not found: " .. snap.path
407
- end
408
-
409
- -- We need deeper parsing for restore — use the full flat parser on the file
410
- -- For simplicity, show a message and use the MCP bridge if available;
411
- -- otherwise we do a best-effort restore.
412
- local ok_parse, parsed = pcall(parse_flat_object, content)
413
- if not ok_parse or not parsed then
414
- return false, "Could not parse snapshot file"
415
- end
416
-
417
- -- The mixerState is a nested object — not directly accessible via parse_flat_object.
418
- -- We'll do a targeted string extraction of the "mixerState" block and parse it.
419
- -- For v2 snapshots this is complex; we perform a simplified restore of
420
- -- volume/pan/mute/solo from a targeted parse.
421
- local mixer_block_start = content:find('"mixerState"%s*:%s*{')
422
- if not mixer_block_start then
423
- return false, "No mixerState found in snapshot"
424
- end
425
-
426
- -- Find "tracks" array
427
- local tracks_start = content:find('"tracks"%s*:%s*%[', mixer_block_start)
428
- if not tracks_start then
429
- return false, "No tracks found in snapshot"
430
- end
431
-
432
- local arr_open = content:find('%[', tracks_start)
433
- if not arr_open then return false, "Invalid tracks format" end
434
-
435
- -- Extract each track object
436
- local track_objects = {}
437
- local depth = 0
438
- local obj_start = nil
439
- local i = arr_open
440
-
441
- while i <= #content do
442
- local ch = content:sub(i, i)
443
- if ch == '{' then
444
- depth = depth + 1
445
- if depth == 1 then obj_start = i end
446
- elseif ch == '}' then
447
- depth = depth - 1
448
- if depth == 0 and obj_start then
449
- track_objects[#track_objects + 1] = content:sub(obj_start, i)
450
- obj_start = nil
451
- end
452
- elseif ch == ']' and depth == 0 then
453
- break
454
- end
455
- i = i + 1
456
- end
457
-
458
- if #track_objects == 0 then
459
- return false, "No track data found in snapshot"
460
- end
461
-
462
- reaper.Undo_BeginBlock()
463
- local restored = 0
464
-
465
- for _, track_json in ipairs(track_objects) do
466
- local ok_t, track_data = pcall(parse_flat_object, track_json)
467
- if ok_t and track_data and track_data.index then
468
- local track_idx = tonumber(track_data.index)
469
- local track = reaper.GetTrack(0, track_idx)
470
- if track then
471
- if track_data.volume then
472
- reaper.SetMediaTrackInfo_Value(track, "D_VOL", tonumber(track_data.volume))
473
- end
474
- if track_data.pan then
475
- reaper.SetMediaTrackInfo_Value(track, "D_PAN", tonumber(track_data.pan))
476
- end
477
- if track_data.mute ~= nil then
478
- local mute_val = (track_data.mute == true or track_data.mute == "true") and 1 or 0
479
- reaper.SetMediaTrackInfo_Value(track, "B_MUTE", mute_val)
480
- end
481
- if track_data.solo ~= nil then
482
- local solo_val = (track_data.solo == true or track_data.solo == "true") and 1 or 0
483
- reaper.SetMediaTrackInfo_Value(track, "I_SOLO", solo_val)
484
- end
485
- restored = restored + 1
486
- end
487
- end
488
- end
489
-
490
- reaper.Undo_EndBlock("MCP Snapshot Manager: Restore '" .. snap.name .. "'", -1)
491
- reaper.TrackList_AdjustWindows(false)
492
- reaper.UpdateArrange()
493
-
494
- return true, nil, restored
495
- end
496
-
497
- local function do_delete_snapshot(snap)
498
- local ok, err = os.remove(snap.path)
499
- if not ok then
500
- return false, "Failed to delete: " .. (err or "unknown error")
501
- end
502
- return true, nil
503
- end
504
-
505
- -- =============================================================================
506
- -- UI helpers
507
- -- =============================================================================
508
-
509
- local function set_color(c)
510
- gfx.r, gfx.g, gfx.b, gfx.a = c[1], c[2], c[3], c[4]
511
- end
512
-
513
- local function fill_rect(x, y, w, h)
514
- gfx.rect(x, y, w, h, 1)
515
- end
516
-
517
- local function draw_rect(x, y, w, h)
518
- gfx.rect(x, y, w, h, 0)
519
- end
520
-
521
- local function draw_text(text, x, y, color, align_right, max_w)
522
- set_color(color or C_TEXT)
523
- if max_w and max_w > 0 then
524
- -- Clip text to max_w (simple truncation)
525
- while #text > 1 do
526
- local tw, _ = gfx.measurestr(text)
527
- if tw <= max_w then break end
528
- text = text:sub(1, -2)
529
- end
530
- end
531
- if align_right then
532
- local tw, _ = gfx.measurestr(text)
533
- x = x - tw
534
- end
535
- gfx.x, gfx.y = x, y
536
- gfx.drawstr(text)
537
- end
538
-
539
- local function draw_button(label, x, y, w, h, bg_normal, bg_hover, is_hover, is_pressed)
540
- local bg = bg_normal
541
- if is_pressed then bg = C_BTN_PRESS
542
- elseif is_hover then bg = bg_hover or C_BTN_HOVER
543
- end
544
- set_color(bg)
545
- fill_rect(x, y, w, h)
546
- -- Border
547
- set_color(C_DIVIDER)
548
- draw_rect(x, y, w, h)
549
- -- Label centered
550
- local tw, th = gfx.measurestr(label)
551
- draw_text(label, x + math.floor((w - tw) / 2), y + math.floor((h - th) / 2), C_TEXT_SEL)
552
- end
553
-
554
- local function point_in_rect(px, py, rx, ry, rw, rh)
555
- return px >= rx and px < rx + rw and py >= ry and py < ry + rh
556
- end
557
-
558
- -- =============================================================================
559
- -- Layout calculations
560
- -- =============================================================================
561
-
562
- local function get_list_rect()
563
- local w = gfx.w
564
- local h = gfx.h
565
- local list_x = PADDING
566
- local list_y = LIST_TOP
567
- local list_w = w - PADDING * 2
568
- local list_h = h - LIST_TOP - LIST_BOTTOM_MARGIN - PADDING
569
- return list_x, list_y, list_w, list_h
570
- end
571
-
572
- local function get_visible_rows(list_h)
573
- return math.floor(list_h / ROW_H)
574
- end
575
-
576
- local function get_button_rects()
577
- local h = gfx.h
578
- local w = gfx.w
579
- local btn_y = h - FOOTER_H + math.floor((FOOTER_H - BUTTON_H) / 2)
580
- local btns = {}
581
- local x = PADDING
582
-
583
- btns.save = { x = x, y = btn_y, w = BUTTON_W, h = BUTTON_H, label = "Save New" }
584
- x = x + BUTTON_W + PADDING
585
-
586
- btns.restore = { x = x, y = btn_y, w = BUTTON_W, h = BUTTON_H, label = "Restore" }
587
- x = x + BUTTON_W + PADDING
588
-
589
- btns.delete = { x = x, y = btn_y, w = BUTTON_W, h = BUTTON_H, label = "Delete" }
590
- x = x + BUTTON_W + PADDING
591
-
592
- btns.refresh = { x = x, y = btn_y, w = BUTTON_W, h = BUTTON_H, label = "Refresh" }
593
-
594
- -- Close button on right side
595
- btns.close = { x = w - BUTTON_W - PADDING, y = btn_y, w = BUTTON_W, h = BUTTON_H, label = "Close" }
596
-
597
- return btns
598
- end
599
-
600
- -- =============================================================================
601
- -- Status message helpers
602
- -- =============================================================================
603
-
604
- local function set_status(msg, stype, duration)
605
- status_msg = msg
606
- status_type = stype or "info"
607
- status_timer = reaper.time_precise() + (duration or 4.0)
608
- end
609
-
610
- -- =============================================================================
611
- -- Main draw function
612
- -- =============================================================================
613
-
614
- local function draw()
615
- local w = gfx.w
616
- local h = gfx.h
617
- local mx = gfx.mouse_x
618
- local my = gfx.mouse_y
619
- local mouse_cap = gfx.mouse_cap
620
- local mouse_down = (mouse_cap & 1) ~= 0
621
-
622
- -- Background
623
- set_color(C_BG)
624
- fill_rect(0, 0, w, h)
625
-
626
- -- Column headers (title is already in the window title bar)
627
- local lx, ly, lw, lh = get_list_rect()
628
- set_color(C_HEADER_BG)
629
- fill_rect(0, 0, w, COL_HEADER_H)
630
- gfx.setfont(1, "Arial", 11, string.byte('b'))
631
- local col_text_y = math.floor((COL_HEADER_H - 11) / 2)
632
- local cx = PADDING + 4
633
- draw_text("Name", cx, col_text_y, C_TEXT_HEADER)
634
- cx = cx + COL_NAME_W
635
- draw_text("Description", cx, col_text_y, C_TEXT_HEADER)
636
- cx = cx + COL_DESC_W
637
- draw_text("Saved", cx, col_text_y, C_TEXT_HEADER)
638
- cx = cx + COL_DATE_W
639
- draw_text("Tracks", cx, col_text_y, C_TEXT_HEADER)
640
- gfx.setfont(1, "Arial", 12, 0)
641
-
642
- -- Divider under col headers
643
- set_color(C_DIVIDER)
644
- fill_rect(0, COL_HEADER_H - 1, w, 1)
645
-
646
- -- Snapshot list
647
- local visible_rows = get_visible_rows(lh)
648
- local total_rows = #snapshots
649
-
650
- -- Clamp scroll
651
- local max_scroll = math.max(0, total_rows - visible_rows)
652
- if scroll_offset > max_scroll then scroll_offset = max_scroll end
653
- if scroll_offset < 0 then scroll_offset = 0 end
654
-
655
- hover_row = 0
656
-
657
- for row = 1, visible_rows do
658
- local snap_idx = row + scroll_offset
659
- local ry = ly + (row - 1) * ROW_H
660
- if ry + ROW_H > ly + lh then break end
661
-
662
- local row_w = lw - SCROLL_BAR_W
663
-
664
- if snap_idx <= total_rows then
665
- local snap = snapshots[snap_idx]
666
- local is_sel = snap_idx == selected_idx
667
- local is_hov = point_in_rect(mx, my, lx, ry, row_w, ROW_H)
668
-
669
- if is_hov then hover_row = snap_idx end
670
-
671
- -- Row background
672
- if is_sel then set_color(C_ROW_SEL)
673
- elseif is_hov then set_color(C_ROW_HOVER)
674
- elseif row % 2 == 0 then set_color(C_ROW_EVEN)
675
- else set_color(C_ROW_ODD)
676
- end
677
- fill_rect(lx, ry, row_w, ROW_H)
678
-
679
- -- Row content
680
- local text_color = is_sel and C_TEXT_SEL or C_TEXT
681
- local dim_color = is_sel and C_TEXT_SEL or C_TEXT_DIM
682
- local text_y = ry + math.floor((ROW_H - 12) / 2)
683
-
684
- local cell_x = lx + 4
685
- draw_text(snap.name, cell_x, text_y, text_color, false, COL_NAME_W - 8)
686
-
687
- cell_x = cell_x + COL_NAME_W
688
- draw_text(snap.description, cell_x, text_y, dim_color, false, COL_DESC_W - 8)
689
-
690
- cell_x = cell_x + COL_DESC_W
691
- draw_text(format_timestamp(snap.timestamp), cell_x, text_y, dim_color, false, COL_DATE_W - 8)
692
-
693
- cell_x = cell_x + COL_DATE_W
694
- local tc_str = snap.trackCount and tostring(snap.trackCount) or "-"
695
- draw_text(tc_str, cell_x, text_y, dim_color)
696
-
697
- -- Row separator
698
- set_color(C_DIVIDER)
699
- fill_rect(lx, ry + ROW_H - 1, row_w, 1)
700
- else
701
- -- Empty row
702
- local bg = (row % 2 == 0) and C_ROW_EVEN or C_ROW_ODD
703
- set_color(bg)
704
- fill_rect(lx, ry, row_w, ROW_H)
705
- end
706
- end
707
-
708
- -- Empty state message
709
- if total_rows == 0 then
710
- local msg = "No snapshots found. Click 'Save New' to capture your current mixer state."
711
- gfx.setfont(1, "Arial", 13, 0)
712
- local tw, _ = gfx.measurestr(msg)
713
- draw_text(msg, lx + math.floor((lw - tw) / 2), ly + math.floor(lh / 2) - 8, C_TEXT_DIM)
714
- gfx.setfont(1, "Arial", 12, 0)
715
- end
716
-
717
- -- Scrollbar
718
- if total_rows > visible_rows then
719
- local sb_x = lx + lw - SCROLL_BAR_W
720
- local sb_y = ly
721
- local sb_h = lh
722
-
723
- set_color(C_SCROLLBAR)
724
- fill_rect(sb_x, sb_y, SCROLL_BAR_W, sb_h)
725
-
726
- local thumb_h = math.max(20, math.floor(sb_h * visible_rows / total_rows))
727
- local thumb_y = sb_y + math.floor((sb_h - thumb_h) * scroll_offset / math.max(1, max_scroll))
728
-
729
- set_color(C_SCROLLTHUMB)
730
- fill_rect(sb_x + 2, thumb_y + 1, SCROLL_BAR_W - 4, thumb_h - 2)
731
- end
732
-
733
- -- Footer divider
734
- set_color(C_DIVIDER)
735
- fill_rect(0, h - FOOTER_H - STATUS_H, w, 1)
736
-
737
- -- Status bar
738
- local status_y = h - STATUS_H - 2
739
- set_color(C_BG)
740
- fill_rect(0, status_y, w, STATUS_H)
741
-
742
- gfx.setfont(1, "Arial", 11, 0)
743
- if status_msg ~= "" and reaper.time_precise() < status_timer then
744
- local sc = C_STATUS_INFO
745
- if status_type == "ok" then sc = C_STATUS_OK
746
- elseif status_type == "err" then sc = C_STATUS_ERR
747
- end
748
- draw_text(status_msg, PADDING, status_y + 4, sc)
749
- else
750
- -- Default info: what snapshots contain
751
- draw_text("Snapshots store: track volumes, pans, mute/solo, FX chains (params + presets), and send levels", PADDING, status_y + 4, C_TEXT_DIM)
752
- end
753
- gfx.setfont(1, "Arial", 12, 0)
754
-
755
- -- Footer buttons
756
- set_color(C_BG)
757
- fill_rect(0, h - FOOTER_H, w, FOOTER_H)
758
- set_color(C_DIVIDER)
759
- fill_rect(0, h - FOOTER_H, w, 1)
760
-
761
- local btns = get_button_rects()
762
- hover_btn = nil
763
-
764
- local has_selection = selected_idx > 0 and selected_idx <= #snapshots
765
-
766
- for btn_name, btn in pairs(btns) do
767
- local is_hov = point_in_rect(mx, my, btn.x, btn.y, btn.w, btn.h)
768
- if is_hov then hover_btn = btn_name end
769
- local is_press = btn_pressed == btn_name
770
-
771
- -- Disable restore/delete if nothing selected
772
- local disabled = (btn_name == "restore" or btn_name == "delete") and not has_selection
773
-
774
- local bg_n = C_BTN_NORMAL
775
- local bg_h = C_BTN_HOVER
776
- if btn_name == "delete" then
777
- bg_n = has_selection and C_BTN_DELETE or C_BTN_NORMAL
778
- bg_h = C_BTN_DEL_HOV
779
- elseif btn_name == "restore" then
780
- bg_n = has_selection and C_BTN_RESTORE or C_BTN_NORMAL
781
- bg_h = C_BTN_RES_HOV
782
- end
783
-
784
- if disabled then
785
- set_color({ bg_n[1] * 0.6, bg_n[2] * 0.6, bg_n[3] * 0.6, 1.0 })
786
- fill_rect(btn.x, btn.y, btn.w, btn.h)
787
- set_color(C_DIVIDER)
788
- draw_rect(btn.x, btn.y, btn.w, btn.h)
789
- local tw, th = gfx.measurestr(btn.label)
790
- draw_text(btn.label, btn.x + math.floor((btn.w - tw) / 2), btn.y + math.floor((btn.h - th) / 2), C_TEXT_DIM)
791
- else
792
- draw_button(btn.label, btn.x, btn.y, btn.w, btn.h, bg_n, bg_h, is_hov and not disabled, is_press)
793
- end
794
- end
795
-
796
- -- Mouse interaction
797
- local mouse_clicked = mouse_was_down and not mouse_down
798
-
799
- if mouse_clicked then
800
- -- Check if clicked on list row
801
- if hover_row > 0 and hover_row <= total_rows then
802
- selected_idx = hover_row
803
- end
804
-
805
- -- Check button clicks
806
- if hover_btn and btn_pressed == hover_btn then
807
- local has_sel = selected_idx > 0 and selected_idx <= #snapshots
808
-
809
- if hover_btn == "save" then
810
- -- Generate default name: "Snapshot N" where N is the next available index
811
- local next_idx = #snapshots + 1
812
- for _, s in ipairs(snapshots) do
813
- local num = s.name:match("^Snapshot (%d+)$")
814
- if num then next_idx = math.max(next_idx, tonumber(num) + 1) end
815
- end
816
- local default_name = "Snapshot " .. next_idx
817
-
818
- -- Show save dialog with default name pre-filled
819
- local retval, inputs = reaper.GetUserInputs(
820
- "Save Snapshot", 2,
821
- "Name (leave default for auto-name):,Description (optional):,extrawidth=200",
822
- default_name .. ","
823
- )
824
- if retval then
825
- local fields = {}
826
- for part in (inputs .. ","):gmatch("([^,]*),") do
827
- fields[#fields + 1] = part
828
- end
829
- local sname = fields[1] and fields[1]:match("^%s*(.-)%s*$") or ""
830
- local sdesc = fields[2] and fields[2]:match("^%s*(.-)%s*$") or ""
831
- if sname == "" then sname = default_name end
832
- local ok, err = do_save_snapshot(sname, sdesc)
833
- if ok then
834
- set_status("Saved snapshot: " .. sname, "ok")
835
- snapshots = load_snapshots()
836
- -- Select the newly saved snapshot
837
- for j, s in ipairs(snapshots) do
838
- if s.name == sname then selected_idx = j; break end
839
- end
840
- else
841
- set_status("Save failed: " .. (err or "unknown error"), "err")
842
- end
843
- end
844
-
845
- elseif hover_btn == "restore" and has_sel then
846
- local snap = snapshots[selected_idx]
847
- local confirm = reaper.ShowMessageBox(
848
- "Restore snapshot '" .. snap.name .. "'?\n\nThis will overwrite current mixer state.\nAn undo point will be created.",
849
- "Restore Snapshot", 1 -- MB_OKCANCEL
850
- )
851
- if confirm == 1 then
852
- local ok, err, count = do_restore_snapshot(snap)
853
- if ok then
854
- set_status("Restored '" .. snap.name .. "' (" .. (count or "?") .. " tracks)", "ok")
855
- else
856
- set_status("Restore failed: " .. (err or "unknown error"), "err")
857
- end
858
- end
859
-
860
- elseif hover_btn == "delete" and has_sel then
861
- local snap = snapshots[selected_idx]
862
- local confirm = reaper.ShowMessageBox(
863
- "Delete snapshot '" .. snap.name .. "'?\n\nThis cannot be undone.",
864
- "Delete Snapshot", 1 -- MB_OKCANCEL
865
- )
866
- if confirm == 1 then
867
- local ok, err = do_delete_snapshot(snap)
868
- if ok then
869
- set_status("Deleted snapshot: " .. snap.name, "ok")
870
- snapshots = load_snapshots()
871
- if selected_idx > #snapshots then
872
- selected_idx = #snapshots
873
- end
874
- else
875
- set_status("Delete failed: " .. (err or "unknown error"), "err")
876
- end
877
- end
878
-
879
- elseif hover_btn == "refresh" then
880
- snapshots = load_snapshots()
881
- set_status("Refreshed — " .. #snapshots .. " snapshot(s) found.", "info", 2.5)
882
-
883
- elseif hover_btn == "close" then
884
- is_running = false
885
- end
886
- end
887
-
888
- btn_pressed = nil
889
- end
890
-
891
- if mouse_down and not mouse_was_down then
892
- -- Start press
893
- if hover_btn then
894
- btn_pressed = hover_btn
895
- end
896
- end
897
-
898
- mouse_was_down = mouse_down
899
- last_mouse_x = mx
900
- last_mouse_y = my
901
- last_mouse_cap = mouse_cap
902
-
903
- -- Keyboard navigation
904
- local char = gfx.getchar()
905
- if char == -1 then
906
- is_running = false -- Window closed
907
- elseif char == 27 then -- Escape
908
- is_running = false
909
- elseif char == 1685026670 or char == 30064 then -- Up arrow
910
- if selected_idx > 1 then
911
- selected_idx = selected_idx - 1
912
- if selected_idx <= scroll_offset then
913
- scroll_offset = selected_idx - 1
914
- end
915
- end
916
- elseif char == 1685026669 or char == 30065 then -- Down arrow
917
- if selected_idx < #snapshots then
918
- selected_idx = selected_idx + 1
919
- local _, _, _, lh2 = get_list_rect()
920
- local vr = get_visible_rows(lh2)
921
- if selected_idx > scroll_offset + vr then
922
- scroll_offset = selected_idx - vr
923
- end
924
- end
925
- elseif char == 13 then -- Enter = restore
926
- if selected_idx > 0 and selected_idx <= #snapshots then
927
- local snap = snapshots[selected_idx]
928
- local confirm = reaper.ShowMessageBox(
929
- "Restore snapshot '" .. snap.name .. "'?\n\nThis will overwrite current mixer state.\nAn undo point will be created.",
930
- "Restore Snapshot", 1
931
- )
932
- if confirm == 1 then
933
- local ok, err, count = do_restore_snapshot(snap)
934
- if ok then
935
- set_status("Restored '" .. snap.name .. "' (" .. (count or "?") .. " tracks)", "ok")
936
- else
937
- set_status("Restore failed: " .. (err or "unknown error"), "err")
938
- end
939
- end
940
- end
941
- elseif char == 6579564 then -- Delete key
942
- if selected_idx > 0 and selected_idx <= #snapshots then
943
- local snap = snapshots[selected_idx]
944
- local confirm = reaper.ShowMessageBox(
945
- "Delete snapshot '" .. snap.name .. "'?\n\nThis cannot be undone.",
946
- "Delete Snapshot", 1
947
- )
948
- if confirm == 1 then
949
- local ok, err = do_delete_snapshot(snap)
950
- if ok then
951
- set_status("Deleted snapshot: " .. snap.name, "ok")
952
- snapshots = load_snapshots()
953
- if selected_idx > #snapshots then selected_idx = #snapshots end
954
- else
955
- set_status("Delete failed: " .. (err or "unknown error"), "err")
956
- end
957
- end
958
- end
959
- end
960
-
961
- -- Mouse wheel scroll
962
- local wheel = gfx.mouse_wheel
963
- if wheel ~= 0 then
964
- local scroll_lines = wheel > 0 and -3 or 3
965
- local _, _, _, lh_scroll = get_list_rect()
966
- local max_scroll_wheel = math.max(0, #snapshots - get_visible_rows(lh_scroll))
967
- scroll_offset = math.max(0, math.min(scroll_offset + scroll_lines, max_scroll_wheel))
968
- gfx.mouse_wheel = 0
969
- end
970
-
971
- gfx.update()
972
- end
973
-
974
- -- =============================================================================
975
- -- Defer loop
976
- -- =============================================================================
977
-
978
- local function loop()
979
- if not is_running then
980
- gfx.quit()
981
- return
982
- end
983
- draw()
984
- reaper.defer(loop)
985
- end
986
-
987
- -- =============================================================================
988
- -- Initialization
989
- -- =============================================================================
990
-
991
- gfx.init(TITLE, WIN_W, WIN_H, 0)
992
- gfx.setfont(1, "Arial", 12, 0)
993
-
994
- snapshots = load_snapshots()
995
- if #snapshots > 0 then
996
- selected_idx = 1
997
- end
998
-
999
- set_status("Loaded " .. #snapshots .. " snapshot(s) from " .. get_snapshot_dir(), "info", 5.0)
1000
-
1001
- reaper.defer(loop)