@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 +17 -4
- package/main.js +5 -1
- package/package.json +1 -1
- package/reaper/CLAUDE.md +4 -0
- package/reaper/install.sh +6 -4
- package/reaper/mcp_snapshot_lib.lua +350 -0
- package/reaper/mcp_snapshot_manager.lua +53 -49
- package/reaper/mcp_snapshot_next.lua +25 -0
- package/reaper/mcp_snapshot_prev.lua +25 -0
- package/reaper/mcp_snapshot_quick_save.lua +41 -0
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
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
--
|
|
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(
|
|
629
|
+
fill_rect(0, 0, w, COL_HEADER_H)
|
|
643
630
|
gfx.setfont(1, "Arial", 11, string.byte('b'))
|
|
644
|
-
local
|
|
645
|
-
|
|
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,
|
|
635
|
+
draw_text("Description", cx, col_text_y, C_TEXT_HEADER)
|
|
648
636
|
cx = cx + COL_DESC_W
|
|
649
|
-
draw_text("Saved", cx,
|
|
637
|
+
draw_text("Saved", cx, col_text_y, C_TEXT_HEADER)
|
|
650
638
|
cx = cx + COL_DATE_W
|
|
651
|
-
draw_text("Tracks", cx,
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
--
|
|
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
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
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
|
-
|
|
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
|