@mthines/reaper-mcp 0.17.0-beta.18.1 → 0.18.0

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 CHANGED
@@ -246,13 +246,25 @@ Checks that the bridge is connected, knowledge is installed, and MCP config exis
246
246
 
247
247
  ### Snapshots (A/B Testing)
248
248
 
249
+ Snapshots capture your full mixer state: track volumes, pans, mute/solo, FX chains (all parameter values, enabled/offline states, presets), and send levels. Use them to A/B compare mix decisions or revert to a known-good state.
250
+
249
251
  | Tool | Description |
250
252
  |------|-------------|
251
- | `snapshot_save` | Save current mixer state (volumes, pans, FX, mutes) |
252
- | `snapshot_restore` | Restore a saved snapshot |
253
- | `snapshot_list` | List all saved snapshots |
253
+ | `snapshot_save` | Save current mixer state (volumes, pans, FX params, sends, mutes) |
254
+ | `snapshot_restore` | Restore a saved snapshot (smart FX matching — only restores params if plugin names match) |
255
+ | `snapshot_list` | List all saved snapshots with metadata |
254
256
  | `snapshot_delete` | Delete a saved snapshot by name |
255
257
 
258
+ You can also manage snapshots directly in REAPER using the **Snapshot Manager** GUI (`mcp_snapshot_manager.lua`) — load it as a ReaScript action for a visual list with save/restore/delete buttons.
259
+
260
+ For quick A/B testing, bind these action scripts to keyboard shortcuts in REAPER:
261
+
262
+ | Script | Purpose | Suggested Key |
263
+ |--------|---------|---------------|
264
+ | `mcp_snapshot_quick_save.lua` | Save snapshot instantly (auto-named) | `Ctrl+Shift+S` |
265
+ | `mcp_snapshot_next.lua` | Restore next snapshot (wraps around) | `Ctrl+Right` |
266
+ | `mcp_snapshot_prev.lua` | Restore previous snapshot (wraps around) | `Ctrl+Left` |
267
+
256
268
  ### Routing
257
269
 
258
270
  | Tool | Description |
@@ -316,7 +328,7 @@ Every change is bracketed by snapshots:
316
328
  1. Agent saves a "Before" snapshot automatically
317
329
  2. Makes all changes
318
330
  3. Saves an "After" snapshot
319
- 4. You can restore either with `snapshot_restore` to A/B compare
331
+ 4. You can restore either with `snapshot_restore` to A/B compare, or use the Snapshot Manager GUI in REAPER
320
332
 
321
333
  ### Genre Awareness
322
334
 
@@ -539,6 +551,7 @@ reaper-mcp/
539
551
  ├── reaper/ # Files installed into REAPER
540
552
  │ ├── mcp_bridge.lua # Persistent Lua bridge
541
553
  │ ├── mcp_snapshot_manager.lua # Snapshot manager GUI (save/restore/delete)
554
+ │ ├── mcp_snapshot_*.lua # Snapshot action scripts (next/prev/quick-save + shared lib)
542
555
  │ ├── mcp_analyzer.jsfx # FFT spectrum analyzer
543
556
  │ ├── mcp_lufs_meter.jsfx # LUFS meter (BS.1770)
544
557
  │ ├── mcp_correlation_meter.jsfx # Stereo correlation meter
package/main.js CHANGED
@@ -2307,7 +2307,11 @@ var REAPER_ASSETS = [
2307
2307
  "mcp_lufs_meter.jsfx",
2308
2308
  "mcp_correlation_meter.jsfx",
2309
2309
  "mcp_crest_factor.jsfx",
2310
- "mcp_snapshot_manager.lua"
2310
+ "mcp_snapshot_manager.lua",
2311
+ "mcp_snapshot_lib.lua",
2312
+ "mcp_snapshot_next.lua",
2313
+ "mcp_snapshot_prev.lua",
2314
+ "mcp_snapshot_quick_save.lua"
2311
2315
  ];
2312
2316
  var MCP_TOOL_NAMES = [
2313
2317
  // project
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mthines/reaper-mcp",
3
- "version": "0.17.0-beta.18.1",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "description": "MCP server for controlling REAPER DAW — real-time mixing, FX control, and frequency analysis for AI agents",
6
6
  "license": "MIT",
package/reaper/CLAUDE.md CHANGED
@@ -8,6 +8,10 @@ Files installed INTO the REAPER DAW by the `setup` command. These run inside REA
8
8
  |------|----------|---------|
9
9
  | `mcp_bridge.lua` | Lua | Persistent bridge: polls for JSON commands, executes ReaScript API, writes responses |
10
10
  | `mcp_snapshot_manager.lua` | Lua | Standalone snapshot manager GUI (gfx-based, save/restore/delete snapshots) |
11
+ | `mcp_snapshot_lib.lua` | Lua | Shared library for snapshot action scripts (path helpers, JSON, restore logic) |
12
+ | `mcp_snapshot_next.lua` | Lua | Action: restore next snapshot (bind to key for A/B testing) |
13
+ | `mcp_snapshot_prev.lua` | Lua | Action: restore previous snapshot (bind to key for A/B testing) |
14
+ | `mcp_snapshot_quick_save.lua` | Lua | Action: save snapshot with auto-name, no dialog |
11
15
  | `mcp_analyzer.jsfx` | JSFX/EEL2 | Real-time FFT spectrum analyzer, writes to gmem[] |
12
16
  | `mcp_lufs_meter.jsfx` | JSFX/EEL2 | LUFS loudness metering |
13
17
  | `mcp_correlation_meter.jsfx` | JSFX/EEL2 | Stereo correlation and width analysis |
package/reaper/install.sh CHANGED
@@ -26,10 +26,12 @@ echo "REAPER resource path: $REAPER_PATH"
26
26
  # Install Lua scripts
27
27
  SCRIPTS_DIR="$REAPER_PATH/Scripts"
28
28
  mkdir -p "$SCRIPTS_DIR"
29
- cp "$SCRIPT_DIR/mcp_bridge.lua" "$SCRIPTS_DIR/mcp_bridge.lua"
30
- echo "Installed: $SCRIPTS_DIR/mcp_bridge.lua"
31
- cp "$SCRIPT_DIR/mcp_snapshot_manager.lua" "$SCRIPTS_DIR/mcp_snapshot_manager.lua"
32
- echo "Installed: $SCRIPTS_DIR/mcp_snapshot_manager.lua"
29
+ for lua in "$SCRIPT_DIR"/*.lua; do
30
+ [ -f "$lua" ] || continue
31
+ fname="$(basename "$lua")"
32
+ cp "$lua" "$SCRIPTS_DIR/$fname"
33
+ echo "Installed: $SCRIPTS_DIR/$fname"
34
+ done
33
35
 
34
36
  # Install JSFX analyzers
35
37
  EFFECTS_DIR="$REAPER_PATH/Effects/reaper-mcp"
@@ -0,0 +1,350 @@
1
+ -- =============================================================================
2
+ -- MCP Snapshot Library — shared helpers for snapshot action scripts
3
+ -- =============================================================================
4
+ -- Loaded via dofile() by snapshot action scripts. Provides path resolution,
5
+ -- JSON parsing, snapshot listing, and restore logic.
6
+ -- =============================================================================
7
+
8
+ local M = {}
9
+
10
+ -- ExtState section for persisting current snapshot index across action calls
11
+ M.EXT_SECTION = "MCP_Snapshots"
12
+ M.EXT_KEY_IDX = "current_index"
13
+
14
+ -- =============================================================================
15
+ -- Path helpers
16
+ -- =============================================================================
17
+
18
+ function M.get_snapshot_dir()
19
+ local proj_path = reaper.GetProjectPath()
20
+ if proj_path and proj_path ~= "" then
21
+ return proj_path .. "/.reaper-mcp/snapshots/"
22
+ end
23
+ return reaper.GetResourcePath() .. "/Scripts/mcp_bridge_data/snapshots/"
24
+ end
25
+
26
+ function M.ensure_snapshot_dir()
27
+ reaper.RecursiveCreateDirectory(M.get_snapshot_dir(), 0)
28
+ end
29
+
30
+ function M.snapshot_path(name)
31
+ local safe = name:gsub("[^%w%-_%.%s]", "_"):gsub("%s+", "_")
32
+ return M.get_snapshot_dir() .. safe .. ".json"
33
+ end
34
+
35
+ -- =============================================================================
36
+ -- File I/O
37
+ -- =============================================================================
38
+
39
+ function M.read_file(path)
40
+ local f = io.open(path, "rb")
41
+ if not f then return nil end
42
+ local content = f:read("*a")
43
+ f:close()
44
+ return content
45
+ end
46
+
47
+ function M.write_file(path, content)
48
+ local f = io.open(path, "wb")
49
+ if not f then return false end
50
+ f:write(content)
51
+ f:close()
52
+ return true
53
+ end
54
+
55
+ -- =============================================================================
56
+ -- Minimal JSON helpers
57
+ -- =============================================================================
58
+
59
+ local function json_encode_string(s)
60
+ s = tostring(s)
61
+ s = s:gsub('\\', '\\\\')
62
+ s = s:gsub('"', '\\"')
63
+ s = s:gsub('\n', '\\n')
64
+ s = s:gsub('\r', '\\r')
65
+ s = s:gsub('\t', '\\t')
66
+ return '"' .. s .. '"'
67
+ end
68
+
69
+ function M.json_encode(val)
70
+ local t = type(val)
71
+ if t == "nil" then return "null"
72
+ elseif t == "boolean" then return val and "true" or "false"
73
+ elseif t == "number" then
74
+ if val ~= val then return "null" end
75
+ return tostring(val)
76
+ elseif t == "string" then return json_encode_string(val)
77
+ elseif t == "table" then
78
+ local is_array = true
79
+ local max_i = 0
80
+ for k, _ in pairs(val) do
81
+ if type(k) ~= "number" or k ~= math.floor(k) or k < 1 then
82
+ is_array = false; break
83
+ end
84
+ if k > max_i then max_i = k end
85
+ end
86
+ if is_array and max_i == #val then
87
+ local parts = {}
88
+ for i = 1, #val do parts[i] = M.json_encode(val[i]) end
89
+ return "[" .. table.concat(parts, ",") .. "]"
90
+ else
91
+ local parts = {}
92
+ for k, v in pairs(val) do
93
+ parts[#parts + 1] = json_encode_string(tostring(k)) .. ":" .. M.json_encode(v)
94
+ end
95
+ return "{" .. table.concat(parts, ",") .. "}"
96
+ end
97
+ end
98
+ return "null"
99
+ end
100
+
101
+ local function extract_json_string(str, pos)
102
+ local result = {}
103
+ local i = pos
104
+ while i <= #str do
105
+ local ch = str:sub(i, i)
106
+ if ch == '\\' then
107
+ local next_ch = str:sub(i + 1, i + 1)
108
+ if next_ch == '"' then result[#result + 1] = '"'
109
+ elseif next_ch == '\\' then result[#result + 1] = '\\'
110
+ elseif next_ch == 'n' then result[#result + 1] = '\n'
111
+ elseif next_ch == 't' then result[#result + 1] = '\t'
112
+ elseif next_ch == '/' then result[#result + 1] = '/'
113
+ else result[#result + 1] = next_ch end
114
+ i = i + 2
115
+ elseif ch == '"' then
116
+ return table.concat(result), i + 1
117
+ else
118
+ result[#result + 1] = ch
119
+ i = i + 1
120
+ end
121
+ end
122
+ return nil, i
123
+ end
124
+
125
+ function M.parse_flat_object(str)
126
+ local obj = {}
127
+ local i = 1
128
+ while i <= #str do
129
+ local key_start = str:find('"', i)
130
+ if not key_start then break end
131
+ local key, after_key = extract_json_string(str, key_start + 1)
132
+ if not key then break end
133
+ i = after_key
134
+ local colon = str:find(':', i)
135
+ if not colon then break end
136
+ local val_start = str:match('^%s*()', colon + 1)
137
+ local ch = str:sub(val_start, val_start)
138
+ if ch == '"' then
139
+ local val, next_pos = extract_json_string(str, val_start + 1)
140
+ if val then obj[key] = val; i = next_pos
141
+ else i = val_start + 1 end
142
+ elseif ch == '{' or ch == '[' then
143
+ local depth = 1
144
+ local close_c = ch == '{' and '}' or ']'
145
+ local j = val_start + 1
146
+ while j <= #str and depth > 0 do
147
+ local c = str:sub(j, j)
148
+ if c == ch then depth = depth + 1
149
+ elseif c == close_c then depth = depth - 1 end
150
+ j = j + 1
151
+ end
152
+ i = j
153
+ else
154
+ local val_end = str:find('[,}%]]', val_start)
155
+ if not val_end then val_end = #str + 1 end
156
+ local raw = str:sub(val_start, val_end - 1):match("^%s*(.-)%s*$")
157
+ if raw == "true" then obj[key] = true
158
+ elseif raw == "false" then obj[key] = false
159
+ elseif raw == "null" then obj[key] = nil
160
+ else obj[key] = tonumber(raw) end
161
+ i = val_end
162
+ end
163
+ end
164
+ return obj
165
+ end
166
+
167
+ -- =============================================================================
168
+ -- Snapshot listing
169
+ -- =============================================================================
170
+
171
+ function M.load_snapshots()
172
+ M.ensure_snapshot_dir()
173
+ local snap_dir = M.get_snapshot_dir()
174
+ local result = {}
175
+ local i = 0
176
+ while true do
177
+ local fn = reaper.EnumerateFiles(snap_dir, i)
178
+ if not fn then break end
179
+ if fn:match("%.json$") then
180
+ local path = snap_dir .. fn
181
+ local content = M.read_file(path)
182
+ if content then
183
+ local ok, snap = pcall(M.parse_flat_object, content)
184
+ if ok and snap and snap.name then
185
+ result[#result + 1] = {
186
+ name = snap.name,
187
+ timestamp = tonumber(snap.timestamp) or 0,
188
+ path = path,
189
+ }
190
+ end
191
+ end
192
+ end
193
+ i = i + 1
194
+ end
195
+ table.sort(result, function(a, b) return a.timestamp > b.timestamp end)
196
+ return result
197
+ end
198
+
199
+ -- =============================================================================
200
+ -- Restore logic
201
+ -- =============================================================================
202
+
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
209
+
210
+ local tracks_start = content:find('"tracks"%s*:%s*%[', mixer_block_start)
211
+ if not tracks_start then return false, "No tracks" end
212
+
213
+ local arr_open = content:find('%[', tracks_start)
214
+ if not arr_open then return false, "Invalid format" end
215
+
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
230
+ end
231
+ elseif ch == ']' and depth == 0 then break end
232
+ i = i + 1
233
+ end
234
+
235
+ if #track_objects == 0 then return false, "No track data" end
236
+
237
+ reaper.Undo_BeginBlock()
238
+ 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))
243
+ 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)
248
+ 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)
251
+ end
252
+ restored = restored + 1
253
+ end
254
+ end
255
+ end
256
+ reaper.Undo_EndBlock("MCP Snapshot: Restore '" .. snap.name .. "'", -1)
257
+ reaper.TrackList_AdjustWindows(false)
258
+ reaper.UpdateArrange()
259
+ return true, nil, restored
260
+ end
261
+
262
+ -- =============================================================================
263
+ -- Capture mixer state
264
+ -- =============================================================================
265
+
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
327
+ end
328
+
329
+ -- =============================================================================
330
+ -- Persisted index helpers
331
+ -- =============================================================================
332
+
333
+ function M.get_current_index()
334
+ local val = reaper.GetExtState(M.EXT_SECTION, M.EXT_KEY_IDX)
335
+ return tonumber(val) or 0
336
+ end
337
+
338
+ function M.set_current_index(idx)
339
+ reaper.SetExtState(M.EXT_SECTION, M.EXT_KEY_IDX, tostring(idx), false)
340
+ end
341
+
342
+ -- =============================================================================
343
+ -- Toast message (brief console feedback)
344
+ -- =============================================================================
345
+
346
+ function M.toast(msg)
347
+ reaper.Help_Set(msg, false)
348
+ end
349
+
350
+ return M
@@ -12,12 +12,12 @@ local WIN_W = 820
12
12
  local WIN_H = 520
13
13
  local PADDING = 10
14
14
  local ROW_H = 26
15
- local HEADER_H = 34
15
+ local COL_HEADER_H = 28
16
16
  local BUTTON_H = 32
17
17
  local BUTTON_W = 120
18
18
  local FOOTER_H = BUTTON_H + PADDING * 2
19
19
  local STATUS_H = 22
20
- local LIST_TOP = HEADER_H + PADDING
20
+ local LIST_TOP = COL_HEADER_H + PADDING
21
21
  local LIST_BOTTOM_MARGIN = FOOTER_H + STATUS_H + PADDING
22
22
  local SCROLL_BAR_W = 14
23
23
 
@@ -238,7 +238,7 @@ local function count_tracks_in_snapshot(content)
238
238
  local count = 0
239
239
  local depth = 0
240
240
  local i = arr_start
241
- while i <= #arr_start + 50000 and i <= #content do
241
+ while i <= math.min(#content, arr_start + 50000) do
242
242
  local ch = content:sub(i, i)
243
243
  if ch == '[' or ch == '{' then
244
244
  depth = depth + 1
@@ -623,37 +623,25 @@ local function draw()
623
623
  set_color(C_BG)
624
624
  fill_rect(0, 0, w, h)
625
625
 
626
- -- Header bar
627
- set_color(C_HEADER_BG)
628
- fill_rect(0, 0, w, HEADER_H)
629
- gfx.setfont(1, "Arial", 16, string.byte('b'))
630
- draw_text(TITLE, PADDING, math.floor((HEADER_H - 16) / 2), C_TEXT_SEL)
631
- gfx.setfont(1, "Arial", 12, 0)
632
-
633
- -- Snapshot dir info
634
- local snap_dir = get_snapshot_dir()
635
- local dir_label = "Dir: " .. snap_dir
636
- draw_text(dir_label, PADDING + 200, math.floor((HEADER_H - 12) / 2) + 2, C_TEXT_DIM, false, w - 220 - PADDING)
637
-
638
- -- Column headers
626
+ -- Column headers (title is already in the window title bar)
639
627
  local lx, ly, lw, lh = get_list_rect()
640
- local col_header_y = ly - ROW_H - 2
641
628
  set_color(C_HEADER_BG)
642
- fill_rect(lx, col_header_y, lw - SCROLL_BAR_W, ROW_H)
629
+ fill_rect(0, 0, w, COL_HEADER_H)
643
630
  gfx.setfont(1, "Arial", 11, string.byte('b'))
644
- local cx = lx + 4
645
- draw_text("Name", cx, col_header_y + 7, C_TEXT_HEADER)
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)
646
634
  cx = cx + COL_NAME_W
647
- draw_text("Description", cx, col_header_y + 7, C_TEXT_HEADER)
635
+ draw_text("Description", cx, col_text_y, C_TEXT_HEADER)
648
636
  cx = cx + COL_DESC_W
649
- draw_text("Saved", cx, col_header_y + 7, C_TEXT_HEADER)
637
+ draw_text("Saved", cx, col_text_y, C_TEXT_HEADER)
650
638
  cx = cx + COL_DATE_W
651
- draw_text("Tracks", cx, col_header_y + 7, C_TEXT_HEADER)
639
+ draw_text("Tracks", cx, col_text_y, C_TEXT_HEADER)
652
640
  gfx.setfont(1, "Arial", 12, 0)
653
641
 
654
642
  -- Divider under col headers
655
643
  set_color(C_DIVIDER)
656
- fill_rect(lx, col_header_y + ROW_H - 1, lw, 1)
644
+ fill_rect(0, COL_HEADER_H - 1, w, 1)
657
645
 
658
646
  -- Snapshot list
659
647
  local visible_rows = get_visible_rows(lh)
@@ -719,7 +707,7 @@ local function draw()
719
707
 
720
708
  -- Empty state message
721
709
  if total_rows == 0 then
722
- local msg = "No snapshots found. Use 'Save New' to create one."
710
+ local msg = "No snapshots found. Click 'Save New' to capture your current mixer state."
723
711
  gfx.setfont(1, "Arial", 13, 0)
724
712
  local tw, _ = gfx.measurestr(msg)
725
713
  draw_text(msg, lx + math.floor((lw - tw) / 2), ly + math.floor(lh / 2) - 8, C_TEXT_DIM)
@@ -751,15 +739,18 @@ local function draw()
751
739
  set_color(C_BG)
752
740
  fill_rect(0, status_y, w, STATUS_H)
753
741
 
742
+ gfx.setfont(1, "Arial", 11, 0)
754
743
  if status_msg ~= "" and reaper.time_precise() < status_timer then
755
744
  local sc = C_STATUS_INFO
756
745
  if status_type == "ok" then sc = C_STATUS_OK
757
746
  elseif status_type == "err" then sc = C_STATUS_ERR
758
747
  end
759
- gfx.setfont(1, "Arial", 11, 0)
760
748
  draw_text(status_msg, PADDING, status_y + 4, sc)
761
- gfx.setfont(1, "Arial", 12, 0)
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)
762
752
  end
753
+ gfx.setfont(1, "Arial", 12, 0)
763
754
 
764
755
  -- Footer buttons
765
756
  set_color(C_BG)
@@ -816,11 +807,19 @@ local function draw()
816
807
  local has_sel = selected_idx > 0 and selected_idx <= #snapshots
817
808
 
818
809
  if hover_btn == "save" then
819
- -- Show save dialog
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
820
819
  local retval, inputs = reaper.GetUserInputs(
821
820
  "Save Snapshot", 2,
822
- "Name:,Description:,extrawidth=200",
823
- ","
821
+ "Name (leave default for auto-name):,Description (optional):,extrawidth=200",
822
+ default_name .. ","
824
823
  )
825
824
  if retval then
826
825
  local fields = {}
@@ -829,20 +828,17 @@ local function draw()
829
828
  end
830
829
  local sname = fields[1] and fields[1]:match("^%s*(.-)%s*$") or ""
831
830
  local sdesc = fields[2] and fields[2]:match("^%s*(.-)%s*$") or ""
832
- if sname == "" then
833
- set_status("Name cannot be empty.", "err")
834
- else
835
- local ok, err = do_save_snapshot(sname, sdesc)
836
- if ok then
837
- set_status("Saved snapshot: " .. sname, "ok")
838
- snapshots = load_snapshots()
839
- -- Select the newly saved snapshot
840
- for j, s in ipairs(snapshots) do
841
- if s.name == sname then selected_idx = j; break end
842
- end
843
- else
844
- set_status("Save failed: " .. (err or "unknown error"), "err")
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
845
839
  end
840
+ else
841
+ set_status("Save failed: " .. (err or "unknown error"), "err")
846
842
  end
847
843
  end
848
844
 
@@ -929,11 +925,17 @@ local function draw()
929
925
  elseif char == 13 then -- Enter = restore
930
926
  if selected_idx > 0 and selected_idx <= #snapshots then
931
927
  local snap = snapshots[selected_idx]
932
- local ok, err, count = do_restore_snapshot(snap)
933
- if ok then
934
- set_status("Restored '" .. snap.name .. "' (" .. (count or "?") .. " tracks)", "ok")
935
- else
936
- set_status("Restore failed: " .. (err or "unknown error"), "err")
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
937
939
  end
938
940
  end
939
941
  elseif char == 6579564 then -- Delete key
@@ -960,7 +962,9 @@ local function draw()
960
962
  local wheel = gfx.mouse_wheel
961
963
  if wheel ~= 0 then
962
964
  local scroll_lines = wheel > 0 and -3 or 3
963
- scroll_offset = math.max(0, math.min(scroll_offset + scroll_lines, math.max(0, #snapshots - get_visible_rows((select(4, get_list_rect()) )))))
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))
964
968
  gfx.mouse_wheel = 0
965
969
  end
966
970
 
@@ -0,0 +1,25 @@
1
+ -- MCP Snapshot: Next — restore the next snapshot in the list
2
+ -- Bind to a key for quick A/B testing. Wraps around at the end.
3
+
4
+ local info = debug.getinfo(1, "S")
5
+ local script_dir = info.source:match("@?(.*[\\/])")
6
+ local lib = dofile(script_dir .. "mcp_snapshot_lib.lua")
7
+
8
+ local snapshots = lib.load_snapshots()
9
+ if #snapshots == 0 then
10
+ lib.toast("No snapshots found")
11
+ return
12
+ end
13
+
14
+ local idx = lib.get_current_index()
15
+ idx = idx + 1
16
+ if idx > #snapshots then idx = 1 end
17
+
18
+ local snap = snapshots[idx]
19
+ local ok, err = lib.restore_snapshot(snap)
20
+ if ok then
21
+ lib.set_current_index(idx)
22
+ lib.toast("Snapshot " .. idx .. "/" .. #snapshots .. ": " .. snap.name)
23
+ else
24
+ lib.toast("Restore failed: " .. (err or "unknown"))
25
+ end
@@ -0,0 +1,25 @@
1
+ -- MCP Snapshot: Previous — restore the previous snapshot in the list
2
+ -- Bind to a key for quick A/B testing. Wraps around at the beginning.
3
+
4
+ local info = debug.getinfo(1, "S")
5
+ local script_dir = info.source:match("@?(.*[\\/])")
6
+ local lib = dofile(script_dir .. "mcp_snapshot_lib.lua")
7
+
8
+ local snapshots = lib.load_snapshots()
9
+ if #snapshots == 0 then
10
+ lib.toast("No snapshots found")
11
+ return
12
+ end
13
+
14
+ local idx = lib.get_current_index()
15
+ idx = idx - 1
16
+ if idx < 1 then idx = #snapshots end
17
+
18
+ local snap = snapshots[idx]
19
+ local ok, err = lib.restore_snapshot(snap)
20
+ if ok then
21
+ lib.set_current_index(idx)
22
+ lib.toast("Snapshot " .. idx .. "/" .. #snapshots .. ": " .. snap.name)
23
+ else
24
+ lib.toast("Restore failed: " .. (err or "unknown"))
25
+ end
@@ -0,0 +1,41 @@
1
+ -- MCP Snapshot: Quick Save — save a snapshot with auto-generated name, no dialog
2
+ -- Bind to a key for rapid iteration. Names: "Snapshot 1", "Snapshot 2", etc.
3
+
4
+ local info = debug.getinfo(1, "S")
5
+ local script_dir = info.source:match("@?(.*[\\/])")
6
+ local lib = dofile(script_dir .. "mcp_snapshot_lib.lua")
7
+
8
+ local snapshots = lib.load_snapshots()
9
+
10
+ -- Find next available "Snapshot N" index
11
+ local next_idx = #snapshots + 1
12
+ for _, s in ipairs(snapshots) do
13
+ local num = s.name:match("^Snapshot (%d+)$")
14
+ if num then next_idx = math.max(next_idx, tonumber(num) + 1) end
15
+ end
16
+
17
+ local name = "Snapshot " .. next_idx
18
+ lib.ensure_snapshot_dir()
19
+
20
+ local snapshot = {
21
+ name = name,
22
+ description = "",
23
+ timestamp = os.time() * 1000,
24
+ mixerState = lib.capture_mixer_state(),
25
+ }
26
+
27
+ local path = lib.snapshot_path(name)
28
+ local ok = lib.write_file(path, lib.json_encode(snapshot))
29
+ if ok then
30
+ -- Set current index to the new snapshot so next/prev starts from here
31
+ local updated = lib.load_snapshots()
32
+ for i, s in ipairs(updated) do
33
+ if s.name == name then
34
+ lib.set_current_index(i)
35
+ break
36
+ end
37
+ end
38
+ lib.toast("Saved: " .. name .. " (" .. #snapshot.mixerState.tracks .. " tracks)")
39
+ else
40
+ lib.toast("Save failed: " .. path)
41
+ end