@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
@@ -12,6 +12,7 @@
12
12
  local POLL_INTERVAL = 0 -- scan every defer cycle; the notify file makes this cheap
13
13
  local HEARTBEAT_INTERVAL = 1.0 -- write heartbeat every 1s
14
14
  local MCP_ANALYZER_FX_NAME = "reaper-mcp/mcp_analyzer" -- JSFX analyzer name
15
+ local OPEN_UI_ON_START = true -- open the status window (reaper_www_root/mcp_bridge.html) when the bridge starts
15
16
 
16
17
  -- Determine bridge directory (REAPER resource path / Scripts / mcp_bridge_data)
17
18
  local bridge_dir = reaper.GetResourcePath() .. "/Scripts/mcp_bridge_data/"
@@ -19,6 +20,54 @@ local bridge_dir = reaper.GetResourcePath() .. "/Scripts/mcp_bridge_data/"
19
20
  -- Ensure bridge directory exists
20
21
  reaper.RecursiveCreateDirectory(bridge_dir, 0)
21
22
 
23
+ -- Plan executor library (run_plan), installed next to this script by `reaper-mcp setup`.
24
+ -- Loaded under pcall so an older install without the file keeps every other tool working.
25
+ local script_dir = debug.getinfo(1, "S").source:match("@?(.*[\\/])") or ""
26
+ local plan_lib_ok, plan_lib = pcall(dofile, script_dir .. "mcp_plan_lib.lua")
27
+ -- Action proxy + selection setters (search/get/run_action, set_selected_*), same install rules.
28
+ local actions_lib_ok, actions_lib = pcall(dofile, script_dir .. "mcp_actions_lib.lua")
29
+ -- Track structure + routing setters (add/delete/move track, sends, groups), same install rules.
30
+ local tracks_lib_ok, tracks_lib = pcall(dofile, script_dir .. "mcp_tracks_lib.lua")
31
+
32
+ -- Track index -1 means the master track, for every tool that takes a
33
+ -- trackIndex. REAPER's own GetTrack returns nil for -1, so nothing relied on it.
34
+ do
35
+ local get_track = reaper.GetTrack
36
+ reaper.GetTrack = function(proj, idx)
37
+ if idx == -1 then return reaper.GetMasterTrack(proj) end
38
+ return get_track(proj, idx)
39
+ end
40
+ end
41
+ -- Project ops, tempo map and marker editing (undo/redo, save, render, settings), same install rules.
42
+ local project_lib_ok, project_lib = pcall(dofile, script_dir .. "mcp_project_lib.lua")
43
+ -- Items, takes, take FX, FX move/copy/rename, .RfxChain and track templates, same install rules.
44
+ local items_lib_ok, items_lib = pcall(dofile, script_dir .. "mcp_items_lib.lua")
45
+ -- Record setup (arm, input, monitoring, record mode), fixed lanes and comping, same install rules.
46
+ -- One local (the main chunk is near Lua's 200-locals limit): the lib, or { load_error = why }.
47
+ -- Lib wrappers added since the main chunk reached the 200-locals limit live here, one field each
48
+ local ext = {}
49
+ ext.recording = (function()
50
+ local ok, lib = pcall(dofile, script_dir .. "mcp_recording_lib.lua")
51
+ if ok then return lib end
52
+ return { load_error = tostring(lib) }
53
+ end)()
54
+ ext.fxstate = (function()
55
+ local ok, lib = pcall(dofile, script_dir .. "mcp_fxstate_lib.lua")
56
+ if ok then return lib end
57
+ return { load_error = tostring(lib) }
58
+ end)()
59
+ ext.extensions = (function()
60
+ local ok, lib = pcall(dofile, script_dir .. "mcp_extensions_lib.lua")
61
+ if ok then return lib end
62
+ return { load_error = tostring(lib) }
63
+ end)()
64
+ -- What a write changed and what a listing showed, for the status page's drawer (same install rules).
65
+ local edit_lib_ok, edit_lib = pcall(dofile, script_dir .. "mcp_edit_views_lib.lua")
66
+ -- Snapshot list, restore and save from the status page (same install rules).
67
+ local snaps_lib_ok, snaps_lib = pcall(dofile, script_dir .. "mcp_snapshots_ui_lib.lua")
68
+ -- LoreKit tokens shared through ~/.config/reaper-mcp (same install rules).
69
+ ext.prefs = (function() local ok, lib = pcall(dofile, script_dir .. "mcp_prefs_lib.lua"); return ok and lib or nil end)()
70
+
22
71
  local last_poll = 0
23
72
  local last_heartbeat = 0
24
73
  local defer_count = 0
@@ -53,6 +102,15 @@ local function extract_json_string(str, pos)
53
102
  result[#result + 1] = '\t'
54
103
  elseif next_ch == '/' then
55
104
  result[#result + 1] = '/'
105
+ elseif next_ch == 'r' then
106
+ result[#result + 1] = '\r'
107
+ elseif next_ch == 'b' then
108
+ result[#result + 1] = '\b'
109
+ elseif next_ch == 'f' then
110
+ result[#result + 1] = '\f'
111
+ elseif next_ch == 'u' and str:sub(i + 2, i + 5):match('^%x%x%x%x$') then
112
+ result[#result + 1] = utf8.char(tonumber(str:sub(i + 2, i + 5), 16))
113
+ i = i + 4
56
114
  else
57
115
  result[#result + 1] = next_ch
58
116
  end
@@ -67,6 +125,29 @@ local function extract_json_string(str, pos)
67
125
  return nil, i -- unterminated string
68
126
  end
69
127
 
128
+ -- The balanced {...} or [...] that starts at `pos`. Unlike %b{} / %b[], it
129
+ -- skips brackets inside strings, so a "}" in a name doesn't end the object.
130
+ local function balanced(str, pos, open, close)
131
+ local pat = open == "{" and '[\\"{}]' or '[\\"%[%]]'
132
+ local depth, in_str, i = 0, false, pos
133
+ while true do
134
+ i = str:find(pat, i)
135
+ if not i then return nil end
136
+ local c = str:sub(i, i)
137
+ if in_str then
138
+ if c == "\\" then i = i + 1 elseif c == '"' then in_str = false end
139
+ elseif c == '"' then
140
+ in_str = true
141
+ elseif c == open then
142
+ depth = depth + 1
143
+ elseif c == close then
144
+ depth = depth - 1
145
+ if depth == 0 then return str:sub(pos, i) end
146
+ end
147
+ i = i + 1
148
+ end
149
+ end
150
+
70
151
  -- Parse a flat JSON object with string/number values in params.
71
152
  -- Handles escaped quotes in string values (e.g. JSON arrays passed as strings).
72
153
  local function parse_flat_object(str)
@@ -99,7 +180,7 @@ local function parse_flat_object(str)
99
180
  end
100
181
  elseif ch == '{' then
101
182
  -- Nested object — extract with balanced braces
102
- local nested = str:match('%b{}', val_start)
183
+ local nested = balanced(str, val_start, "{", "}")
103
184
  if nested then
104
185
  obj[key] = parse_flat_object(nested)
105
186
  i = val_start + #nested
@@ -108,7 +189,7 @@ local function parse_flat_object(str)
108
189
  end
109
190
  elseif ch == '[' then
110
191
  -- Array — extract with balanced brackets
111
- local arr = str:match('%b[]', val_start)
192
+ local arr = balanced(str, val_start, "[", "]")
112
193
  if arr then
113
194
  obj[key] = arr -- keep as string for json_decode_array to parse later
114
195
  i = val_start + #arr
@@ -117,7 +198,8 @@ local function parse_flat_object(str)
117
198
  end
118
199
  elseif ch:match('[%d%-]') then
119
200
  -- Number
120
- local num_str = str:match('-?%d+%.?%d*', val_start)
201
+ local num_str = str:match('^-?%d+%.?%d*[eE][-+]?%d+', val_start)
202
+ or str:match('^-?%d+%.?%d*', val_start)
121
203
  if num_str then
122
204
  obj[key] = tonumber(num_str)
123
205
  i = val_start + #num_str
@@ -166,20 +248,40 @@ local function json_decode_array(input)
166
248
 
167
249
  -- Fallback: extract each {...} from the array and parse individually
168
250
  local arr = {}
169
- for obj_str in input:gmatch("%b{}") do
251
+ local i = 1
252
+ while true do
253
+ local s = input:find("{", i, true)
254
+ local obj_str = s and balanced(input, s, "{", "}")
255
+ if not obj_str then break end
170
256
  arr[#arr + 1] = parse_flat_object(obj_str)
257
+ i = s + #obj_str
171
258
  end
172
- if #arr > 0 then return arr end
259
+ if #arr > 0 or input:match("^%s*%[%s*%]%s*$") then return arr end
173
260
  return nil
174
261
  end
175
262
 
263
+ -- JSON forbids raw control characters in strings. One tab in a track or FX
264
+ -- name would otherwise make every response that carries it unparseable.
265
+ local JSON_ESCAPES = {
266
+ ['"'] = '\\"', ['\\'] = '\\\\', ['\b'] = '\\b', ['\f'] = '\\f',
267
+ ['\n'] = '\\n', ['\r'] = '\\r', ['\t'] = '\\t',
268
+ }
269
+
270
+ local function json_quote(s)
271
+ -- Explicit byte range, not %c: %c follows the C locale and, under a UTF-8
272
+ -- locale, also matches some bytes >= 0x80 (0x80-0x9F on macOS), which are
273
+ -- UTF-8 continuation bytes ("Æ" is C3 86) and would mangle names.
274
+ return '"' .. s:gsub('[\0-\31\127"\\]', function(c)
275
+ return JSON_ESCAPES[c] or string.format('\\u%04x', c:byte())
276
+ end) .. '"'
277
+ end
278
+
176
279
  local function json_encode(obj)
177
280
  -- Simple JSON encoder for our response objects
178
- local parts = {}
179
281
  local function encode_value(v)
180
282
  local t = type(v)
181
283
  if t == "string" then
182
- return '"' .. v:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n') .. '"'
284
+ return json_quote(v)
183
285
  elseif t == "number" then
184
286
  if v ~= v then return "null" end -- NaN
185
287
  if v == math.huge or v == -math.huge then return "null" end
@@ -199,7 +301,7 @@ local function json_encode(obj)
199
301
  -- Object
200
302
  local items = {}
201
303
  for k, val in pairs(v) do
202
- items[#items + 1] = '"' .. tostring(k) .. '":' .. encode_value(val)
304
+ items[#items + 1] = json_quote(tostring(k)) .. ':' .. encode_value(val)
203
305
  end
204
306
  return "{" .. table.concat(items, ",") .. "}"
205
307
  end
@@ -457,7 +559,7 @@ function handlers.get_project_info(params)
457
559
  local proj_path = reaper.GetProjectPath()
458
560
  local track_count = reaper.CountTracks(0)
459
561
  local tempo = reaper.Master_GetTempo()
460
- local _, ts_num, ts_den = reaper.TimeMap_GetTimeSigAtTime(0, 0)
562
+ local ts_num, ts_den = reaper.TimeMap_GetTimeSigAtTime(0, 0)
461
563
  local sr = reaper.GetSetProjectInfo(0, "PROJECT_SRATE", 0, false)
462
564
  local play_state = reaper.GetPlayState() -- 0=stopped, 1=playing, 2=paused, 4=recording
463
565
  local cursor_pos = reaper.GetCursorPosition()
@@ -588,6 +690,18 @@ function handlers.set_track_property(params)
588
690
  reaper.SetMediaTrackInfo_Value(track, "B_MUTE", value)
589
691
  elseif prop == "solo" then
590
692
  reaper.SetMediaTrackInfo_Value(track, "I_SOLO", value)
693
+ elseif prop == "recordArm" then
694
+ reaper.SetMediaTrackInfo_Value(track, "I_RECARM", value)
695
+ elseif prop == "phase" then
696
+ reaper.SetMediaTrackInfo_Value(track, "B_PHASE", value)
697
+ elseif prop == "input" then
698
+ reaper.SetMediaTrackInfo_Value(track, "I_RECINPUT", value)
699
+ elseif prop == "playOffsetMs" then
700
+ -- Track playback offset (REAPER 6.0+): D_PLAY_OFFSET is in seconds unless
701
+ -- flag &2 says samples; &1 bypasses it. Clear both so the value applies.
702
+ reaper.SetMediaTrackInfo_Value(track, "D_PLAY_OFFSET", value / 1000)
703
+ local flag = reaper.GetMediaTrackInfo_Value(track, "I_PLAY_OFFSET_FLAG")
704
+ reaper.SetMediaTrackInfo_Value(track, "I_PLAY_OFFSET_FLAG", flag & ~3)
591
705
  else
592
706
  reaper.Undo_EndBlock("MCP: Set track property", -1)
593
707
  return nil, "Unknown property: " .. tostring(prop)
@@ -607,10 +721,15 @@ function handlers.add_fx(params)
607
721
  local track = reaper.GetTrack(0, idx)
608
722
  if not track then return nil, "Track " .. idx .. " not found" end
609
723
 
610
- local position = params.position or -1
724
+ -- TrackFX_AddByName's last argument is `instantiate`, not a slot: -1 appends, 0 only
725
+ -- looks up an existing instance (so a new FX is "not found"), -1000 - n inserts at slot n.
726
+ local instantiate = -1
727
+ if params.position ~= nil and params.position >= 0 then
728
+ instantiate = -1000 - math.floor(params.position)
729
+ end
611
730
 
612
731
  reaper.Undo_BeginBlock()
613
- local fx_idx = reaper.TrackFX_AddByName(track, fx_name, false, position)
732
+ local fx_idx = reaper.TrackFX_AddByName(track, fx_name, false, instantiate)
614
733
  if fx_idx < 0 then
615
734
  reaper.Undo_EndBlock("MCP: Add FX (failed)", -1)
616
735
  return nil, "FX not found: " .. fx_name
@@ -874,6 +993,10 @@ function handlers.read_track_meters(params)
874
993
  }
875
994
  end
876
995
 
996
+ -- Forward declaration: defined further below (line ~1618). Declared here so the
997
+ -- earlier handlers.read_track_spectrum can capture it as an upvalue.
998
+ local ensure_jsfx_on_track
999
+
877
1000
  function handlers.read_track_spectrum(params)
878
1001
  local idx = params.trackIndex
879
1002
  if not idx then return nil, "trackIndex required" end
@@ -887,23 +1010,49 @@ function handlers.read_track_spectrum(params)
887
1010
  return nil, err or "MCP Spectrum Analyzer JSFX not found. Run 'reaper-mcp setup' to install it."
888
1011
  end
889
1012
 
1013
+ -- Apply the requested FFT size to the analyzer's slider1 (snapped to the sizes
1014
+ -- the JSFX supports). JSFX params via TrackFX_(Get|Set)Param use RAW slider
1015
+ -- values, not normalized 0-1 (verified: get_fx_parameters reports FFT Size in
1016
+ -- its 512..8192 range, and SetParam clamps out-of-range values to the slider
1017
+ -- min). Guard with GetParam so repeated polls at the same size don't reset the
1018
+ -- FFT accumulator (the JSFX @slider zeroes its buffers only when the size changes).
1019
+ local fft_size = params.fftSize or 4096
1020
+ fft_size = fft_size <= 512 and 512
1021
+ or fft_size <= 1024 and 1024
1022
+ or fft_size <= 2048 and 2048
1023
+ or fft_size <= 4096 and 4096
1024
+ or 8192
1025
+ local current_fft = reaper.TrackFX_GetParam(track, analyzer_idx, 0)
1026
+ if math.abs(current_fft - fft_size) > 0.5 then
1027
+ reaper.TrackFX_SetParam(track, analyzer_idx, 0, fft_size)
1028
+ end
1029
+
1030
+ -- Give this instance its own gmem block (master -1 -> slot 0), as read_track_lufs
1031
+ -- does; with one shared block every track's analyzer overwrote the others.
1032
+ local slot = idx + 1
1033
+ local current_slot = reaper.TrackFX_GetParam(track, analyzer_idx, 3)
1034
+ if math.abs(current_slot - slot) > 0.5 then
1035
+ reaper.TrackFX_SetParam(track, analyzer_idx, 3, slot)
1036
+ end
1037
+
890
1038
  -- Read spectrum data from gmem
891
- -- JSFX writes: gmem[0] = bin_count, gmem[1] = peak_db, gmem[2] = rms_db, gmem[3..] = bins
1039
+ -- JSFX writes at base = slot * 4100: [base] = bin_count, [base+1] = peak_db,
1040
+ -- [base+2] = rms_db, [base+3..] = bins
892
1041
  reaper.gmem_attach("MCPAnalyzer")
1042
+ local base = slot * 4100
893
1043
 
894
- local bin_count = reaper.gmem_read(0)
1044
+ local bin_count = reaper.gmem_read(base)
895
1045
  if bin_count <= 0 then
896
1046
  return nil, "Spectrum analyzer not producing data yet. Ensure audio is playing."
897
1047
  end
898
1048
 
899
- local peak_db = reaper.gmem_read(1)
900
- local rms_db = reaper.gmem_read(2)
1049
+ local peak_db = reaper.gmem_read(base + 1)
1050
+ local rms_db = reaper.gmem_read(base + 2)
901
1051
  local sr = reaper.GetSetProjectInfo(0, "PROJECT_SRATE", 0, false)
902
- local fft_size = params.fftSize or 4096
903
1052
 
904
1053
  local bins = {}
905
1054
  for i = 0, bin_count - 1 do
906
- bins[#bins + 1] = reaper.gmem_read(3 + i)
1055
+ bins[#bins + 1] = reaper.gmem_read(base + 3 + i)
907
1056
  end
908
1057
 
909
1058
  return {
@@ -935,6 +1084,15 @@ function handlers.stop(params)
935
1084
  end
936
1085
 
937
1086
  function handlers.record(params)
1087
+ -- With no track armed REAPER opens a modal "Record Warning", which blocks the
1088
+ -- main thread and with it the bridge until a person closes it (seen in 7.40).
1089
+ local armed = false
1090
+ for i = 0, reaper.CountTracks(0) - 1 do
1091
+ if reaper.GetMediaTrackInfo_Value(reaper.GetTrack(0, i), "I_RECARM") ~= 0 then armed = true break end
1092
+ end
1093
+ if not armed then
1094
+ return nil, "No track is armed for recording (REAPER would open a dialog that blocks the bridge). Arm a track first with set_track_record or set_track_property recordArm"
1095
+ end
938
1096
  -- Action 1013 = Transport: Record
939
1097
  reaper.Main_OnCommand(1013, 0)
940
1098
  return { success = true }
@@ -945,7 +1103,7 @@ function handlers.get_transport_state(params)
945
1103
  local cursor_pos = reaper.GetCursorPosition()
946
1104
  local play_pos = reaper.GetPlayPosition()
947
1105
  local tempo = reaper.Master_GetTempo()
948
- local _, ts_num, ts_den = reaper.TimeMap_GetTimeSigAtTime(0, 0)
1106
+ local ts_num, ts_den = reaper.TimeMap_GetTimeSigAtTime(0, 0)
949
1107
 
950
1108
  return {
951
1109
  playing = (play_state & 1) ~= 0,
@@ -973,59 +1131,47 @@ end
973
1131
 
974
1132
  -- Helper: detect FX type prefix from name
975
1133
  local function fx_type_from_name(name)
976
- if name:match("^VST3:") then return "VST3"
977
- elseif name:match("^VST:") then return "VST"
1134
+ -- Instruments carry an "i" (VST3i:, VSTi:, AUi:, CLAPi:) but are the same plugin formats
1135
+ if name:match("^VST3i?:") then return "VST3"
1136
+ elseif name:match("^VSTi?:") then return "VST"
978
1137
  elseif name:match("^JS:") then return "JS"
979
- elseif name:match("^AU:") then return "AU"
980
- elseif name:match("^CLAP:") then return "CLAP"
1138
+ elseif name:match("^AUi?:") then return "AU"
1139
+ elseif name:match("^CLAPi?:") then return "CLAP"
981
1140
  else return "JS" end -- default for bare JSFX names
982
1141
  end
983
1142
 
984
- -- Enumerate all installed FX using SWS CF_EnumerateInstalledFX if available,
985
- -- otherwise fall back to scanning open projects via TrackFX_AddByName probe.
986
- -- The SWS approach is preferred and more complete.
987
- function handlers.list_available_fx(params)
988
- local category_filter = params.category
989
- local fx_list = {}
990
-
991
- -- Try SWS CF_EnumerateInstalledFX (requires SWS extension)
992
- if reaper.CF_EnumerateInstalledFX then
993
- local i = 0
994
- while true do
995
- local ok, name, ident = reaper.CF_EnumerateInstalledFX(i)
996
- if not ok then break end
997
- local fx_type = fx_type_from_name(name)
998
- if not category_filter or fx_type:lower() == category_filter:lower() then
999
- fx_list[#fx_list + 1] = {
1000
- name = name,
1001
- type = fx_type,
1002
- path = ident or "",
1003
- }
1004
- end
1005
- i = i + 1
1143
+ -- Every installed FX as REAPER lists it in the FX browser ("VST3: Pro-Q 3 (FabFilter)"), via the native
1144
+ -- EnumInstalledFX (REAPER 6.37+). nil when this REAPER is too old to enumerate.
1145
+ -- Not reaper-fxfolders.ini: that file holds the FX browser's folder layout (paths, filters), not plugins.
1146
+ local function enumerate_installed_fx()
1147
+ if type(reaper.EnumInstalledFX) ~= "function" then return nil end
1148
+ local list, i = {}, 0
1149
+ while true do
1150
+ local ok, name, ident = reaper.EnumInstalledFX(i)
1151
+ if not ok or type(name) ~= "string" or name == "" then break end
1152
+ -- JSFX libraries (.jsfx-inc) show up in the list but can't be loaded as effects
1153
+ if not (name:find("%.jsfx%-inc") or (ident and ident:find("%.jsfx%-inc"))) then
1154
+ list[#list + 1] = { name = name, type = fx_type_from_name(name), ident = ident or "" }
1006
1155
  end
1007
- return { fxList = fx_list, total = #fx_list, source = "sws" }
1156
+ i = i + 1
1008
1157
  end
1158
+ return list
1159
+ end
1009
1160
 
1010
- -- Fallback: parse reaper-fxfolders.ini for plugin names
1011
- local resource_path = reaper.GetResourcePath()
1012
- local ini_path = resource_path .. "/reaper-fxfolders.ini"
1013
- local content = read_file(ini_path)
1014
- if content then
1015
- for line in content:gmatch("[^\r\n]+") do
1016
- local name = line:match("^Item%d+=(.+)$")
1017
- if name and name ~= "" then
1018
- local fx_type = fx_type_from_name(name)
1019
- if not category_filter or fx_type:lower() == category_filter:lower() then
1020
- fx_list[#fx_list + 1] = { name = name, type = fx_type }
1021
- end
1022
- end
1161
+ function handlers.list_available_fx(params)
1162
+ local category_filter = params.category
1163
+ local all = enumerate_installed_fx()
1164
+ if not all then
1165
+ return { fxList = {}, total = 0, source = "none",
1166
+ warning = "This REAPER cannot enumerate installed FX (EnumInstalledFX needs REAPER 6.37+)." }
1167
+ end
1168
+ local fx_list = {}
1169
+ for _, fx in ipairs(all) do
1170
+ if not category_filter or fx.type:lower() == category_filter:lower() then
1171
+ fx_list[#fx_list + 1] = { name = fx.name, type = fx.type, path = fx.ident }
1023
1172
  end
1024
- return { fxList = fx_list, total = #fx_list, source = "fxfolders_ini" }
1025
1173
  end
1026
-
1027
- return { fxList = fx_list, total = 0, source = "none",
1028
- warning = "SWS not installed and reaper-fxfolders.ini not found. Install SWS Extensions for full FX enumeration." }
1174
+ return { fxList = fx_list, total = #fx_list, source = "reaper" }
1029
1175
  end
1030
1176
 
1031
1177
  function handlers.search_fx(params)
@@ -1048,6 +1194,8 @@ function handlers.search_fx(params)
1048
1194
  return { query = query, matches = matches, total = #matches }
1049
1195
  end
1050
1196
 
1197
+ local PRESET_LIST_MAX = 256
1198
+
1051
1199
  function handlers.get_fx_preset_list(params)
1052
1200
  local track_idx = params.trackIndex
1053
1201
  local fx_idx = params.fxIndex
@@ -1058,22 +1206,49 @@ function handlers.get_fx_preset_list(params)
1058
1206
  local track = reaper.GetTrack(0, track_idx)
1059
1207
  if not track then return nil, "Track " .. track_idx .. " not found" end
1060
1208
 
1061
- local preset_count = reaper.TrackFX_GetPresetIndex(track, fx_idx)
1062
1209
  local presets = {}
1063
1210
 
1064
- -- TrackFX_GetPresetIndex returns current index and total count
1065
- -- We iterate by cycling through presets
1066
- -- Wrap in undo block to prevent intermediate preset changes from polluting undo history
1067
- local _, total = reaper.TrackFX_GetPresetIndex(track, fx_idx)
1211
+ -- Names are only readable by loading each preset, so the FX state is saved first and put back
1212
+ -- afterwards. Restoring the starting preset index alone is not enough: with no preset active
1213
+ -- it is -1, which loads REAPER's default preset instead of the previous settings.
1214
+ local current, total = reaper.TrackFX_GetPresetIndex(track, fx_idx)
1215
+ if total and total > PRESET_LIST_MAX then
1216
+ -- e.g. Kontakt's 2048 "Program N" slots: cycling them is slow, and each load can send
1217
+ -- program changes, so only report the current preset
1218
+ local _, current_preset = reaper.TrackFX_GetPreset(track, fx_idx)
1219
+ return {
1220
+ trackIndex = track_idx, fxIndex = fx_idx, total = total, truncated = true,
1221
+ currentPreset = current_preset,
1222
+ presets = {},
1223
+ note = "More than " .. PRESET_LIST_MAX .. " presets (usually generic program slots); not enumerated",
1224
+ }
1225
+ end
1068
1226
  if total and total > 0 then
1227
+ local chunk
1228
+ if reaper.TrackFX_GetNamedConfigParm then
1229
+ local ok, c = reaper.TrackFX_GetNamedConfigParm(track, fx_idx, "vst_chunk")
1230
+ if ok and c ~= "" then chunk = c end
1231
+ end
1232
+ local params = {}
1233
+ for p = 0, reaper.TrackFX_GetNumParams(track, fx_idx) - 1 do
1234
+ params[p] = reaper.TrackFX_GetParamNormalized(track, fx_idx, p)
1235
+ end
1236
+
1237
+ -- Undo block keeps the intermediate preset loads out of the undo history
1069
1238
  reaper.Undo_BeginBlock()
1070
1239
  for i = 0, total - 1 do
1071
1240
  reaper.TrackFX_SetPresetByIndex(track, fx_idx, i)
1072
1241
  local _, preset_name = reaper.TrackFX_GetPreset(track, fx_idx)
1073
1242
  presets[#presets + 1] = { index = i, name = preset_name or ("Preset " .. i) }
1074
1243
  end
1075
- -- Restore original preset
1076
- reaper.TrackFX_SetPresetByIndex(track, fx_idx, preset_count)
1244
+ if current and current >= 0 then reaper.TrackFX_SetPresetByIndex(track, fx_idx, current) end
1245
+ if chunk then reaper.TrackFX_SetNamedConfigParm(track, fx_idx, "vst_chunk", chunk) end
1246
+ -- Params too: JSFX have no vst_chunk, and a plugin may not apply the chunk to its params
1247
+ for p, v in pairs(params) do
1248
+ if reaper.TrackFX_GetParamNormalized(track, fx_idx, p) ~= v then
1249
+ reaper.TrackFX_SetParamNormalized(track, fx_idx, p, v)
1250
+ end
1251
+ end
1077
1252
  reaper.Undo_EndBlock("MCP: Get FX preset list", -1)
1078
1253
  else
1079
1254
  -- Plugin doesn't report preset count; return current preset only
@@ -1141,90 +1316,185 @@ local function snapshot_path(name)
1141
1316
  return get_snapshot_dir() .. safe .. ".json"
1142
1317
  end
1143
1318
 
1319
+ -- Base64 (standard alphabet). Used to store REAPER track state chunks inside the
1320
+ -- snapshot JSON: chunks contain quotes, newlines and {GUID} braces that would
1321
+ -- break json_encode and especially the fallback %b{} decoder. Base64's alphabet
1322
+ -- (A-Za-z0-9+/=) is safe for both the encoder and either decoder.
1323
+ local B64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
1324
+ local B64_LOOKUP = {}
1325
+ for _b = 1, #B64_CHARS do B64_LOOKUP[B64_CHARS:sub(_b, _b)] = _b - 1 end
1326
+
1327
+ local function base64_encode(data)
1328
+ if not data or data == "" then return "" end
1329
+ local out = {}
1330
+ local len = #data
1331
+ local i = 1
1332
+ while i <= len do
1333
+ local b1 = data:byte(i)
1334
+ local b2 = (i + 1 <= len) and data:byte(i + 1) or 0
1335
+ local b3 = (i + 2 <= len) and data:byte(i + 2) or 0
1336
+ local n = b1 * 65536 + b2 * 256 + b3
1337
+ local c1 = math.floor(n / 262144) % 64
1338
+ local c2 = math.floor(n / 4096) % 64
1339
+ local c3 = math.floor(n / 64) % 64
1340
+ local c4 = n % 64
1341
+ out[#out + 1] = B64_CHARS:sub(c1 + 1, c1 + 1)
1342
+ out[#out + 1] = B64_CHARS:sub(c2 + 1, c2 + 1)
1343
+ out[#out + 1] = (i + 1 <= len) and B64_CHARS:sub(c3 + 1, c3 + 1) or '='
1344
+ out[#out + 1] = (i + 2 <= len) and B64_CHARS:sub(c4 + 1, c4 + 1) or '='
1345
+ i = i + 3
1346
+ end
1347
+ return table.concat(out)
1348
+ end
1349
+
1350
+ local function base64_decode(data)
1351
+ if not data or data == "" then return "" end
1352
+ data = data:gsub('[^A-Za-z0-9+/]', '') -- strip padding/whitespace/newlines
1353
+ local out = {}
1354
+ local len = #data
1355
+ local i = 1
1356
+ while i <= len do
1357
+ local c1 = B64_LOOKUP[data:sub(i, i)] or 0
1358
+ local c2 = B64_LOOKUP[data:sub(i + 1, i + 1)] or 0
1359
+ local c3 = B64_LOOKUP[data:sub(i + 2, i + 2)]
1360
+ local c4 = B64_LOOKUP[data:sub(i + 3, i + 3)]
1361
+ local n = c1 * 262144 + c2 * 4096 + (c3 or 0) * 64 + (c4 or 0)
1362
+ out[#out + 1] = string.char(math.floor(n / 65536) % 256)
1363
+ if c3 ~= nil then out[#out + 1] = string.char(math.floor(n / 256) % 256) end
1364
+ if c4 ~= nil then out[#out + 1] = string.char(n % 256) end
1365
+ i = i + 4
1366
+ end
1367
+ return table.concat(out)
1368
+ end
1369
+
1370
+ -- Parse the trackIndices param, which under the fallback JSON parser arrives as a
1371
+ -- raw "[...]" string and under CF_Json_Parse as a Lua table. Returns a list of
1372
+ -- 0-based indices, or nil if not provided.
1373
+ local function parse_track_indices(value)
1374
+ if value == nil then return nil end
1375
+ if type(value) == "table" then
1376
+ local out = {}
1377
+ for _, v in ipairs(value) do
1378
+ local n = tonumber(v)
1379
+ if n then out[#out + 1] = math.floor(n) end
1380
+ end
1381
+ return (#out > 0) and out or nil
1382
+ end
1383
+ if type(value) == "string" then
1384
+ local out = {}
1385
+ for n in value:gmatch("%-?%d+") do out[#out + 1] = tonumber(n) end
1386
+ return (#out > 0) and out or nil
1387
+ end
1388
+ return nil
1389
+ end
1390
+
1144
1391
  local function capture_mixer_state(params)
1145
1392
  params = params or {}
1146
1393
  local include_fx_params = params.includeFxParams ~= false -- default true
1147
1394
  local max_params = params.maxParamsPerFx or 500
1148
- local state = { version = 2, tracks = {} }
1395
+
1396
+ -- Optional scope: capture only these 0-based track indices (omit = all tracks).
1397
+ local scope = parse_track_indices(params.trackIndices)
1398
+
1399
+ -- fullChunk: capture exact track state chunks (faithful plugin internal state,
1400
+ -- e.g. Helix Native snapshots) instead of parameter values. Defaults to true
1401
+ -- when scoped (the user wants an exact A/B of specific tracks), false otherwise
1402
+ -- (whole-project chunk snapshots would be very large).
1403
+ local full_chunk = params.fullChunk
1404
+ if full_chunk == nil then full_chunk = (scope ~= nil) end
1405
+
1406
+ local state = { version = 2, tracks = {}, fullChunk = full_chunk and true or false }
1149
1407
  local count = reaper.CountTracks(0)
1150
1408
 
1151
- for i = 0, count - 1 do
1409
+ -- Build the ordered list of track indices to capture.
1410
+ local indices = {}
1411
+ if scope then
1412
+ state.scoped = true
1413
+ for _, idx in ipairs(scope) do
1414
+ if idx >= 0 and idx < count then indices[#indices + 1] = idx end
1415
+ end
1416
+ else
1417
+ for i = 0, count - 1 do indices[#indices + 1] = i end
1418
+ end
1419
+
1420
+ for _, i in ipairs(indices) do
1152
1421
  local track = reaper.GetTrack(0, i)
1153
- local _, name = reaper.GetTrackName(track)
1154
- local vol = reaper.GetMediaTrackInfo_Value(track, "D_VOL")
1155
- local pan = reaper.GetMediaTrackInfo_Value(track, "D_PAN")
1156
- local mute = reaper.GetMediaTrackInfo_Value(track, "B_MUTE")
1157
- local solo = reaper.GetMediaTrackInfo_Value(track, "I_SOLO")
1158
- local color = reaper.GetMediaTrackInfo_Value(track, "I_CUSTOMCOLOR")
1422
+ if track then
1423
+ local _, name = reaper.GetTrackName(track)
1424
+ local track_entry = {
1425
+ index = i,
1426
+ name = name,
1427
+ color = reaper.GetMediaTrackInfo_Value(track, "I_CUSTOMCOLOR"),
1428
+ volume = reaper.GetMediaTrackInfo_Value(track, "D_VOL"),
1429
+ pan = reaper.GetMediaTrackInfo_Value(track, "D_PAN"),
1430
+ mute = reaper.GetMediaTrackInfo_Value(track, "B_MUTE") ~= 0,
1431
+ solo = reaper.GetMediaTrackInfo_Value(track, "I_SOLO") ~= 0,
1432
+ }
1159
1433
 
1160
- -- FX state with full parameter capture
1161
- local fx_count = reaper.TrackFX_GetCount(track)
1162
- local fx_states = {}
1163
- for j = 0, fx_count - 1 do
1164
- local enabled = reaper.TrackFX_GetEnabled(track, j)
1165
- local offline = reaper.TrackFX_GetOffline(track, j)
1166
- local _, preset = reaper.TrackFX_GetPreset(track, j)
1167
- local _, fx_name = reaper.TrackFX_GetFXName(track, j)
1168
-
1169
- local params_data = {}
1170
- if include_fx_params then
1171
- local param_count = reaper.TrackFX_GetNumParams(track, j)
1172
- local limit = math.min(param_count, max_params)
1173
- for p = 0, limit - 1 do
1174
- local val = reaper.TrackFX_GetParam(track, j, p)
1175
- params_data[p + 1] = val
1434
+ if full_chunk then
1435
+ -- Exact, complete track state: FX chain + each plugin's internal VST
1436
+ -- state (incl. Helix snapshot selection), routing, envelopes. Base64 so
1437
+ -- the chunk's quotes/newlines/{GUID} braces survive the JSON round-trip.
1438
+ local ok, chunk = reaper.GetTrackStateChunk(track, "", false)
1439
+ if ok and chunk and chunk ~= "" then
1440
+ track_entry.chunkB64 = base64_encode(chunk)
1441
+ end
1442
+ else
1443
+ -- Lightweight: FX bypass/offline + parameter values + send levels (the
1444
+ -- original, backward-compatible capture).
1445
+ local fx_count = reaper.TrackFX_GetCount(track)
1446
+ local fx_states = {}
1447
+ for j = 0, fx_count - 1 do
1448
+ local _, preset = reaper.TrackFX_GetPreset(track, j)
1449
+ local _, fx_name = reaper.TrackFX_GetFXName(track, j)
1450
+ local params_data = {}
1451
+ if include_fx_params then
1452
+ local param_count = reaper.TrackFX_GetNumParams(track, j)
1453
+ local limit = math.min(param_count, max_params)
1454
+ for p = 0, limit - 1 do
1455
+ params_data[p + 1] = reaper.TrackFX_GetParam(track, j, p)
1456
+ end
1457
+ end
1458
+ fx_states[j + 1] = {
1459
+ name = fx_name,
1460
+ enabled = reaper.TrackFX_GetEnabled(track, j),
1461
+ offline = reaper.TrackFX_GetOffline(track, j),
1462
+ preset = preset or "",
1463
+ params = params_data,
1464
+ }
1176
1465
  end
1177
- end
1178
1466
 
1179
- fx_states[j + 1] = {
1180
- name = fx_name,
1181
- enabled = enabled,
1182
- offline = offline,
1183
- preset = preset or "",
1184
- params = params_data,
1185
- }
1186
- end
1467
+ local send_count = reaper.GetTrackNumSends(track, 0)
1468
+ local sends = {}
1469
+ for s = 0, send_count - 1 do
1470
+ local dest_track = reaper.GetTrackSendInfo_Value(track, 0, s, "P_DESTTRACK")
1471
+ local dest_idx = -1
1472
+ local dest_name = ""
1473
+ if dest_track then
1474
+ dest_idx = reaper.GetMediaTrackInfo_Value(dest_track, "IP_TRACKNUMBER") - 1
1475
+ local _, dname = reaper.GetTrackName(dest_track)
1476
+ dest_name = dname or ""
1477
+ end
1478
+ sends[s + 1] = {
1479
+ destTrackIndex = dest_idx,
1480
+ destTrackName = dest_name,
1481
+ volume = reaper.GetTrackSendInfo_Value(track, 0, s, "D_VOL"),
1482
+ pan = reaper.GetTrackSendInfo_Value(track, 0, s, "D_PAN"),
1483
+ muted = reaper.GetTrackSendInfo_Value(track, 0, s, "B_MUTE") ~= 0,
1484
+ }
1485
+ end
1187
1486
 
1188
- -- Send levels (volume, pan, mute for existing sends)
1189
- local send_count = reaper.GetTrackNumSends(track, 0)
1190
- local sends = {}
1191
- for s = 0, send_count - 1 do
1192
- local dest_track = reaper.GetTrackSendInfo_Value(track, 0, s, "P_DESTTRACK")
1193
- local dest_idx = -1
1194
- local dest_name = ""
1195
- if dest_track then
1196
- dest_idx = reaper.GetMediaTrackInfo_Value(dest_track, "IP_TRACKNUMBER") - 1
1197
- local _, dname = reaper.GetTrackName(dest_track)
1198
- dest_name = dname or ""
1487
+ track_entry.fx = fx_states
1488
+ track_entry.sends = sends
1489
+ track_entry.fxEnabled = (function()
1490
+ local arr = {}
1491
+ for j = 1, #fx_states do arr[j] = fx_states[j].enabled end
1492
+ return arr
1493
+ end)()
1199
1494
  end
1200
- sends[s + 1] = {
1201
- destTrackIndex = dest_idx,
1202
- destTrackName = dest_name,
1203
- volume = reaper.GetTrackSendInfo_Value(track, 0, s, "D_VOL"),
1204
- pan = reaper.GetTrackSendInfo_Value(track, 0, s, "D_PAN"),
1205
- muted = reaper.GetTrackSendInfo_Value(track, 0, s, "B_MUTE") ~= 0,
1206
- }
1207
- end
1208
1495
 
1209
- state.tracks[i + 1] = {
1210
- index = i,
1211
- name = name,
1212
- color = color,
1213
- volume = vol,
1214
- pan = pan,
1215
- mute = mute ~= 0,
1216
- solo = solo ~= 0,
1217
- fx = fx_states,
1218
- sends = sends,
1219
- -- Legacy compat: flat fxEnabled array (derived from fx_states)
1220
- fxEnabled = (function()
1221
- local arr = {}
1222
- for j = 1, #fx_states do
1223
- arr[j] = fx_states[j].enabled
1224
- end
1225
- return arr
1226
- end)(),
1227
- }
1496
+ state.tracks[#state.tracks + 1] = track_entry
1497
+ end
1228
1498
  end
1229
1499
  return state
1230
1500
  end
@@ -1238,23 +1508,40 @@ function handlers.snapshot_save(params)
1238
1508
  ensure_snapshot_dir()
1239
1509
 
1240
1510
  local timestamp = os.time() * 1000
1241
- local snapshot = {
1242
- name = name,
1243
- description = params.description or "",
1244
- timestamp = timestamp,
1245
- mixerState = capture_mixer_state({
1246
- includeFxParams = params.includeFxParams ~= false,
1247
- maxParamsPerFx = params.maxParamsPerFx or 500,
1248
- }),
1249
- }
1511
+ local mixer_state = capture_mixer_state({
1512
+ includeFxParams = params.includeFxParams ~= false,
1513
+ maxParamsPerFx = params.maxParamsPerFx or 500,
1514
+ trackIndices = params.trackIndices,
1515
+ fullChunk = params.fullChunk,
1516
+ })
1517
+ -- Metadata first, mixerState last: a lister can read name/description/trackCount from the
1518
+ -- head of the file (mcp_snapshot_lib.lua's read_snapshot_meta reads only that prefix)
1519
+ local content = "{" .. table.concat({
1520
+ '"name":' .. json_encode(name),
1521
+ '"description":' .. json_encode(params.description or ""),
1522
+ '"timestamp":' .. json_encode(timestamp),
1523
+ '"trackCount":' .. #mixer_state.tracks,
1524
+ '"scoped":' .. tostring(mixer_state.scoped == true),
1525
+ '"fullChunk":' .. tostring(mixer_state.fullChunk == true),
1526
+ '"mixerState":' .. json_encode(mixer_state),
1527
+ }, ",") .. "}"
1250
1528
 
1251
1529
  local path = snapshot_path(name)
1252
- local ok = write_file(path, json_encode(snapshot))
1530
+ local ok = write_file(path, content)
1253
1531
  if not ok then
1254
1532
  return nil, "Failed to write snapshot file: " .. path
1255
1533
  end
1256
1534
 
1257
- return { success = true, name = name, timestamp = timestamp, path = path, storageLocation = get_snapshot_storage_location() }
1535
+ return {
1536
+ success = true,
1537
+ name = name,
1538
+ timestamp = timestamp,
1539
+ path = path,
1540
+ storageLocation = get_snapshot_storage_location(),
1541
+ scoped = mixer_state.scoped == true,
1542
+ fullChunk = mixer_state.fullChunk == true,
1543
+ tracksCaptured = #mixer_state.tracks,
1544
+ }
1258
1545
  end
1259
1546
 
1260
1547
  function handlers.snapshot_restore(params)
@@ -1305,6 +1592,18 @@ function handlers.snapshot_restore(params)
1305
1592
 
1306
1593
  local track = reaper.GetTrack(0, track_state.index)
1307
1594
  if track then
1595
+ if track_state.chunkB64 then
1596
+ -- Exact restore: replace the entire track state from the saved chunk.
1597
+ -- This faithfully reproduces each plugin's internal state (incl. Helix
1598
+ -- Native snapshot selection) which parameter-value restore cannot. The
1599
+ -- chunk is this track's own state only, so children (e.g. guitars under
1600
+ -- a folder) are untouched.
1601
+ if reaper.SetTrackStateChunk(track, base64_decode(track_state.chunkB64), false) then
1602
+ restored = restored + 1
1603
+ else
1604
+ warnings[#warnings + 1] = "Track " .. track_state.index .. ": SetTrackStateChunk failed"
1605
+ end
1606
+ else
1308
1607
  -- Basic mixer state (all versions)
1309
1608
  reaper.SetMediaTrackInfo_Value(track, "D_VOL", track_state.volume)
1310
1609
  reaper.SetMediaTrackInfo_Value(track, "D_PAN", track_state.pan)
@@ -1375,6 +1674,7 @@ function handlers.snapshot_restore(params)
1375
1674
  end
1376
1675
 
1377
1676
  restored = restored + 1
1677
+ end
1378
1678
  end
1379
1679
  end
1380
1680
  end
@@ -1446,6 +1746,80 @@ function handlers.snapshot_delete(params)
1446
1746
  return { name = name, deleted = true }
1447
1747
  end
1448
1748
 
1749
+ -- Rename a snapshot and/or change its description; the mixer state is kept. A new name that saves to
1750
+ -- another snapshot's file is refused (pass the same name to change only the description).
1751
+ function handlers.snapshot_edit(params)
1752
+ local name = params.name
1753
+ if not name or name == "" then
1754
+ return nil, "name required"
1755
+ end
1756
+ local new_name = params.newName
1757
+ if new_name ~= nil then
1758
+ new_name = tostring(new_name):match("^%s*(.-)%s*$")
1759
+ if new_name == "" then return nil, "newName must not be empty" end
1760
+ else
1761
+ new_name = name
1762
+ end
1763
+
1764
+ local path = snapshot_path(name)
1765
+ local content = read_file(path)
1766
+ if not content then
1767
+ return nil, "Snapshot not found: " .. name
1768
+ end
1769
+ -- The mixer state is copied as written: the bridge's fallback decoder is flat, so it is never re-encoded.
1770
+ -- A JSON string can't hold a bare `"mixerState":` (its quotes would be escaped), so the first one is the key.
1771
+ local snapshot = json_decode(content)
1772
+ local key_end = select(2, content:find('"mixerState"%s*:%s*'))
1773
+ local raw_state
1774
+ if key_end and content:sub(key_end + 1, key_end + 1) == "{" then
1775
+ local depth, in_str, i = 0, false, key_end + 1
1776
+ while i <= #content do
1777
+ local c = content:sub(i, i)
1778
+ if in_str then
1779
+ if c == "\\" then i = i + 1 elseif c == '"' then in_str = false end
1780
+ elseif c == '"' then in_str = true
1781
+ elseif c == "{" or c == "[" then depth = depth + 1
1782
+ elseif c == "}" or c == "]" then
1783
+ depth = depth - 1
1784
+ if depth == 0 then raw_state = content:sub(key_end + 1, i); break end
1785
+ end
1786
+ i = i + 1
1787
+ end
1788
+ end
1789
+ if not snapshot or not raw_state then
1790
+ return nil, "Invalid snapshot file for: " .. name
1791
+ end
1792
+ local new_path = snapshot_path(new_name)
1793
+ -- A case-only rename is the same file on macOS and Windows: never remove it after writing
1794
+ local same_file = new_path:lower() == path:lower()
1795
+ if not same_file and read_file(new_path) then
1796
+ return nil, "A snapshot already saves to that name's file: " .. new_name
1797
+ end
1798
+
1799
+ local description = params.description
1800
+ if description == nil then description = snapshot.description or "" end
1801
+ local ok, state = pcall(plan_lib_ok and plan_lib.json_decode or json_decode, raw_state)
1802
+ if not ok or type(state) ~= "table" then state = {} end
1803
+ local edited = "{" .. table.concat({
1804
+ '"name":' .. json_encode(new_name),
1805
+ '"description":' .. json_encode(tostring(description)),
1806
+ '"timestamp":' .. json_encode(snapshot.timestamp or os.time() * 1000),
1807
+ '"trackCount":' .. (type(state.tracks) == "table" and #state.tracks or tonumber(snapshot.trackCount) or 0),
1808
+ '"scoped":' .. tostring(state.scoped == true or snapshot.scoped == true),
1809
+ '"fullChunk":' .. tostring(state.fullChunk == true or snapshot.fullChunk == true),
1810
+ '"mixerState":' .. raw_state,
1811
+ }, ",") .. "}"
1812
+ if not write_file(new_path, edited) then
1813
+ return nil, "Failed to write snapshot file: " .. new_path
1814
+ end
1815
+ if not same_file then os.remove(path) end
1816
+ -- The next/prev actions continue from the current snapshot by name
1817
+ if new_name ~= name and reaper.GetExtState("MCP_Snapshots", "current_name") == name then
1818
+ reaper.SetExtState("MCP_Snapshots", "current_name", new_name, false)
1819
+ end
1820
+ return { name = new_name, previousName = name, description = tostring(description), path = new_path }
1821
+ end
1822
+
1449
1823
  -- =============================================================================
1450
1824
  -- Routing handler
1451
1825
  -- =============================================================================
@@ -1611,40 +1985,104 @@ local function find_or_create_mcp_container(track)
1611
1985
  return idx
1612
1986
  end
1613
1987
 
1988
+ -- remove_mcp_meters: delete the "MCP Meters" container and any loose MCP meter
1989
+ -- JSFX that the metering reads inserted. Leaves the MIDI emitter alone.
1990
+ -- params.trackIndices: list of track indices (-1 = master)
1991
+ function handlers.remove_mcp_meters(params)
1992
+ local indices = params.trackIndices
1993
+ -- Without CF_Json_Parse the command parser hands arrays over as a JSON string
1994
+ if type(indices) == "string" then
1995
+ local list = {}
1996
+ for n in indices:gmatch("%-?%d+") do list[#list + 1] = tonumber(n) end
1997
+ indices = list
1998
+ end
1999
+ if type(indices) ~= "table" or #indices == 0 then
2000
+ return nil, "trackIndices required"
2001
+ end
2002
+ local removed = {}
2003
+ reaper.Undo_BeginBlock()
2004
+ for _, idx in ipairs(indices) do
2005
+ local track = reaper.GetTrack(0, idx)
2006
+ local count = 0
2007
+ if track then
2008
+ for i = reaper.TrackFX_GetCount(track) - 1, 0, -1 do
2009
+ local _, name = reaper.TrackFX_GetFXName(track, i)
2010
+ local is_meter = name and (name == "MCP Meters"
2011
+ or (name:find(MCP_FX_PREFIX, 1, true) and not name:find("MIDI Emitter", 1, true)))
2012
+ if is_meter then
2013
+ reaper.TrackFX_Delete(track, i)
2014
+ count = count + 1
2015
+ end
2016
+ end
2017
+ container_cache[tostring(track)] = nil
2018
+ end
2019
+ removed[#removed + 1] = { trackIndex = idx, removed = count, found = track ~= nil }
2020
+ end
2021
+ reaper.Undo_EndBlock("MCP: Remove meters", -1)
2022
+ return { tracks = removed }
2023
+ end
2024
+
1614
2025
  -- Helper: find or auto-insert a named JSFX on a track.
1615
2026
  -- Tries to place inside an "MCP Meters" FX Container (REAPER 7.06+).
1616
2027
  -- Falls back to direct insertion on older REAPER versions.
1617
2028
  -- Returns the FX index (possibly container-addressed) on success, or nil + error.
1618
- local function ensure_jsfx_on_track(track, fx_name)
2029
+ function ensure_jsfx_on_track(track, fx_name)
2030
+ -- REAPER names a JSFX "JS: <desc> [<path>]" but leaves the path out for some
2031
+ -- (seen live: the MIDI emitter is just "JS: MCP MIDI Emitter"), so match either.
2032
+ local desc = ({
2033
+ ["reaper-mcp/mcp_analyzer"] = "JS: MCP Spectrum Analyzer",
2034
+ ["reaper-mcp/mcp_lufs_meter"] = "JS: MCP LUFS Meter",
2035
+ ["reaper-mcp/mcp_correlation_meter"] = "JS: MCP Correlation Meter",
2036
+ ["reaper-mcp/mcp_crest_factor"] = "JS: MCP Crest Factor Meter",
2037
+ ["reaper-mcp/mcp_midi_emitter"] = "JS: MCP MIDI Emitter",
2038
+ })[fx_name]
2039
+ local function is_it(name)
2040
+ return name and (name:find(fx_name, 1, true) or (desc and name:find(desc, 1, true) == 1))
2041
+ end
2042
+
1619
2043
  -- Try container approach first (REAPER 7.06+)
1620
2044
  local container_idx = find_or_create_mcp_container(track)
1621
2045
  if container_idx then
2046
+ -- Seen live (REAPER 7.40): a container added as a track's only FX comes up
2047
+ -- bypassed, so the meters inside never see audio and read zeros. It only
2048
+ -- holds pass-through meters, so it is safe to keep it enabled.
2049
+ if not reaper.TrackFX_GetEnabled(track, container_idx) then
2050
+ reaper.TrackFX_SetEnabled(track, container_idx, true)
2051
+ end
1622
2052
  local ok, count_str = reaper.TrackFX_GetNamedConfigParm(track, container_idx, "container_count")
1623
2053
  local count = tonumber(count_str) or 0
1624
2054
 
1625
- -- Search inside container for existing JSFX
1626
- for i = 0, count - 1 do
1627
- local _, addr_str = reaper.TrackFX_GetNamedConfigParm(track, container_idx, "container_item." .. i)
1628
- local addr = tonumber(addr_str)
1629
- if addr then
1630
- local _, name = reaper.TrackFX_GetFXName(track, addr)
1631
- if name and name:find(fx_name, 1, true) then
1632
- return addr -- Found inside container
2055
+ local function find_in_container()
2056
+ local _, n_str = reaper.TrackFX_GetNamedConfigParm(track, container_idx, "container_count")
2057
+ for i = 0, (tonumber(n_str) or 0) - 1 do
2058
+ local _, addr_str = reaper.TrackFX_GetNamedConfigParm(track, container_idx, "container_item." .. i)
2059
+ local addr = tonumber(addr_str)
2060
+ if addr then
2061
+ local _, name = reaper.TrackFX_GetFXName(track, addr)
2062
+ if is_it(name) then return addr end
1633
2063
  end
1634
2064
  end
1635
2065
  end
1636
2066
 
2067
+ local found = find_in_container()
2068
+ if found then return found end
2069
+
1637
2070
  -- Not found — insert inside container
1638
2071
  -- Use legacy addressing: 0x2000000 + (1-based slot) * (top_chain_count + 1) + (1-based container pos)
1639
2072
  local top_count = reaper.TrackFX_GetCount(track)
1640
2073
  local slot = count + 1 -- 1-based, next empty slot
1641
2074
  local container_pos = container_idx + 1 -- 1-based
1642
2075
  local insert_addr = 0x2000000 + slot * (top_count + 1) + container_pos
1643
- local new_idx = reaper.TrackFX_AddByName(track, fx_name, false, insert_addr)
1644
- if new_idx >= 0 then
2076
+ if reaper.TrackFX_AddByName(track, fx_name, false, insert_addr) >= 0 then
1645
2077
  -- Invalidate cache since FX count changed
1646
2078
  container_cache[tostring(track)] = nil
1647
- return new_idx
2079
+ -- Look the new meter up by name: the index TrackFX_AddByName returns for a
2080
+ -- container insert is not its address. Callers set slider params on the
2081
+ -- returned index, and the wrong index wrote them into the track's first FX
2082
+ -- (seen live: Pro-Q 4 band 1 gain pushed to +30 dB).
2083
+ found = find_in_container()
2084
+ if found then return found end
2085
+ return nil, "Internal error: " .. fx_name .. " was inserted but could not be found in the MCP Meters container"
1648
2086
  end
1649
2087
  -- Container insertion failed — fall through to direct
1650
2088
  end
@@ -1653,7 +2091,7 @@ local function ensure_jsfx_on_track(track, fx_name)
1653
2091
  local fx_count = reaper.TrackFX_GetCount(track)
1654
2092
  for i = 0, fx_count - 1 do
1655
2093
  local _, name = reaper.TrackFX_GetFXName(track, i)
1656
- if name and name:find(fx_name, 1, true) then
2094
+ if is_it(name) then
1657
2095
  return i
1658
2096
  end
1659
2097
  end
@@ -1674,17 +2112,21 @@ function handlers.read_track_lufs(params)
1674
2112
  local fx_idx, err = ensure_jsfx_on_track(track, MCP_LUFS_METER_FX_NAME)
1675
2113
  if not fx_idx then return nil, err end
1676
2114
 
1677
- -- Set the track_slot parameter so this instance writes to a unique gmem offset
1678
- local desired_slot = idx / 127
2115
+ -- Give this instance its own gmem slot (master -1 -> 0). TrackFX_SetParam
2116
+ -- takes the RAW slider value; changing it resets the measurement.
2117
+ local slot = idx + 1
1679
2118
  local current_slot = reaper.TrackFX_GetParam(track, fx_idx, 1)
1680
- if math.abs(current_slot - desired_slot) > 0.001 then
1681
- reaper.TrackFX_SetParam(track, fx_idx, 1, desired_slot)
2119
+ if math.abs(current_slot - slot) > 0.5 then
2120
+ reaper.TrackFX_SetParam(track, fx_idx, 1, slot)
2121
+ end
2122
+ if params.reset then
2123
+ reaper.TrackFX_SetParam(track, fx_idx, 0, 1) -- the Reset slider; the JSFX sets it back to 0
1682
2124
  end
1683
2125
 
1684
2126
  -- Read from gmem (JSFX writes here from @sample)
1685
2127
  reaper.gmem_attach("MCPLufsMeter")
1686
2128
 
1687
- local base = idx * 8
2129
+ local base = slot * 8
1688
2130
  local integrated = reaper.gmem_read(base + 0)
1689
2131
  local short_term = reaper.gmem_read(base + 1)
1690
2132
  local momentary = reaper.gmem_read(base + 2)
@@ -1828,6 +2270,25 @@ local function ppq_to_beats(take, item)
1828
2270
  end
1829
2271
  end
1830
2272
 
2273
+ -- Helper: count a take's channel events by type. MIDI_CountEvts' "cc" count is
2274
+ -- every channel event MIDI_GetCC enumerates (CC, pitch bend, program change,
2275
+ -- channel/poly pressure), so ccCount comes from here: CC messages (0xB0) only.
2276
+ local CHANNEL_EVENT_KINDS = {
2277
+ [176] = "cc", [224] = "pitchBend", [192] = "programChange",
2278
+ [208] = "channelPressure", [160] = "polyAftertouch",
2279
+ }
2280
+
2281
+ local function count_channel_events(take)
2282
+ local _, _, evt_cnt, _ = reaper.MIDI_CountEvts(take)
2283
+ local counts = { cc = 0, pitchBend = 0, programChange = 0, channelPressure = 0, polyAftertouch = 0, other = 0 }
2284
+ for i = 0, evt_cnt - 1 do
2285
+ local _, _, _, _, chanmsg = reaper.MIDI_GetCC(take, i)
2286
+ local kind = CHANNEL_EVENT_KINDS[chanmsg] or "other"
2287
+ counts[kind] = counts[kind] + 1
2288
+ end
2289
+ return counts.cc, evt_cnt, counts
2290
+ end
2291
+
1831
2292
  function handlers.create_midi_item(params)
1832
2293
  local track_idx = params.trackIndex
1833
2294
  local start_pos = params.startPosition
@@ -1878,13 +2339,16 @@ function handlers.list_midi_items(params)
1878
2339
  local item = reaper.GetTrackMediaItem(track, i)
1879
2340
  local take = reaper.GetActiveTake(item)
1880
2341
  if take and reaper.TakeIsMIDI(take) then
1881
- local _, note_cnt, cc_cnt, _ = reaper.MIDI_CountEvts(take)
2342
+ local _, note_cnt, _, _ = reaper.MIDI_CountEvts(take)
2343
+ local cc_cnt, evt_cnt, evt_counts = count_channel_events(take)
1882
2344
  items[#items + 1] = {
1883
2345
  itemIndex = i,
1884
2346
  position = reaper.GetMediaItemInfo_Value(item, "D_POSITION"),
1885
2347
  length = reaper.GetMediaItemInfo_Value(item, "D_LENGTH"),
1886
2348
  noteCount = note_cnt,
1887
2349
  ccCount = cc_cnt,
2350
+ channelEventCount = evt_cnt,
2351
+ channelEventCounts = evt_counts,
1888
2352
  muted = reaper.GetMediaItemInfo_Value(item, "B_MUTE") ~= 0,
1889
2353
  }
1890
2354
  end
@@ -1940,7 +2404,8 @@ function handlers.analyze_midi(params)
1940
2404
  local track, item, take, err = get_midi_take(params)
1941
2405
  if err then return nil, err end
1942
2406
 
1943
- local _, note_cnt, cc_cnt, _ = reaper.MIDI_CountEvts(take)
2407
+ local _, note_cnt, _, _ = reaper.MIDI_CountEvts(take)
2408
+ local cc_cnt, evt_cnt, evt_counts = count_channel_events(take)
1944
2409
  local to_beats = ppq_to_beats(take, item)
1945
2410
 
1946
2411
  -- Per-pitch accumulators
@@ -2028,6 +2493,8 @@ function handlers.analyze_midi(params)
2028
2493
  itemIndex = params.itemIndex,
2029
2494
  totalNotes = note_cnt,
2030
2495
  totalCC = cc_cnt,
2496
+ channelEventCount = evt_cnt,
2497
+ channelEventCounts = evt_counts,
2031
2498
  durationBeats = math.floor(max_end_beat * 100 + 0.5) / 100,
2032
2499
  pitchStats = pitch_stats,
2033
2500
  velocityHistogram = vel_histogram,
@@ -2060,6 +2527,7 @@ function handlers.insert_midi_note(params)
2060
2527
  reaper.Undo_BeginBlock()
2061
2528
  reaper.MIDI_InsertNote(take, false, false, start_ppq, end_ppq, chan, pitch, vel, false)
2062
2529
  reaper.MIDI_Sort(take)
2530
+ reaper.MarkTrackItemsDirty(track, item) -- else REAPER merges this into the previous MIDI undo point
2063
2531
  reaper.Undo_EndBlock("MCP: Insert MIDI note", -1)
2064
2532
 
2065
2533
  reaper.UpdateArrange()
@@ -2103,6 +2571,7 @@ function handlers.insert_midi_notes(params)
2103
2571
  end
2104
2572
  end
2105
2573
  reaper.MIDI_Sort(take)
2574
+ reaper.MarkTrackItemsDirty(track, item) -- else REAPER merges this into the previous MIDI undo point
2106
2575
  reaper.Undo_EndBlock("MCP: Insert " .. inserted .. " MIDI notes", -1)
2107
2576
 
2108
2577
  reaper.UpdateArrange()
@@ -2152,6 +2621,7 @@ function handlers.edit_midi_note(params)
2152
2621
  reaper.Undo_BeginBlock()
2153
2622
  reaper.MIDI_SetNote(take, note_idx, sel, muted, new_start_ppq, new_end_ppq, new_chan, new_pitch, new_vel, false)
2154
2623
  reaper.MIDI_Sort(take)
2624
+ reaper.MarkTrackItemsDirty(track, item) -- else REAPER merges this into the previous MIDI undo point
2155
2625
  reaper.Undo_EndBlock("MCP: Edit MIDI note " .. note_idx, -1)
2156
2626
 
2157
2627
  reaper.UpdateArrange()
@@ -2210,6 +2680,7 @@ function handlers.edit_midi_notes(params)
2210
2680
  end
2211
2681
 
2212
2682
  reaper.MIDI_Sort(take)
2683
+ reaper.MarkTrackItemsDirty(track, item) -- else REAPER merges this into the previous MIDI undo point
2213
2684
  reaper.Undo_EndBlock("MCP: Batch edit " .. edited .. " MIDI notes", -1)
2214
2685
  reaper.UpdateArrange()
2215
2686
 
@@ -2232,6 +2703,7 @@ function handlers.delete_midi_note(params)
2232
2703
 
2233
2704
  reaper.Undo_BeginBlock()
2234
2705
  reaper.MIDI_DeleteNote(take, note_idx)
2706
+ reaper.MarkTrackItemsDirty(track, item) -- else REAPER merges this into the previous MIDI undo point
2235
2707
  reaper.Undo_EndBlock("MCP: Delete MIDI note " .. note_idx, -1)
2236
2708
 
2237
2709
  reaper.UpdateArrange()
@@ -2291,12 +2763,13 @@ function handlers.insert_midi_cc(params)
2291
2763
  -- chanmsg 176 = 0xB0 = CC message
2292
2764
  reaper.MIDI_InsertCC(take, false, false, ppq, 176, chan, cc_num, value)
2293
2765
  reaper.MIDI_Sort(take)
2766
+ reaper.MarkTrackItemsDirty(track, item) -- else REAPER merges this into the previous MIDI undo point
2294
2767
  reaper.Undo_EndBlock("MCP: Insert MIDI CC " .. cc_num, -1)
2295
2768
 
2296
2769
  reaper.UpdateArrange()
2297
2770
 
2298
- local _, _, cc_cnt, _ = reaper.MIDI_CountEvts(take)
2299
- return { success = true, ccCount = cc_cnt }
2771
+ local cc_cnt, evt_cnt = count_channel_events(take)
2772
+ return { success = true, ccCount = cc_cnt, channelEventCount = evt_cnt }
2300
2773
  end
2301
2774
 
2302
2775
  function handlers.delete_midi_cc(params)
@@ -2306,26 +2779,29 @@ function handlers.delete_midi_cc(params)
2306
2779
  local cc_idx = params.ccIndex
2307
2780
  if cc_idx == nil then return nil, "ccIndex required" end
2308
2781
 
2309
- local _, _, cc_cnt, _ = reaper.MIDI_CountEvts(take)
2310
- if cc_idx >= cc_cnt then
2311
- return nil, "CC index " .. cc_idx .. " out of range (item has " .. cc_cnt .. " CC events)"
2782
+ -- ccIndex addresses every channel event (as get_midi_cc reports it), not CCs only
2783
+ local _, _, evt_cnt, _ = reaper.MIDI_CountEvts(take)
2784
+ if cc_idx >= evt_cnt then
2785
+ return nil, "CC index " .. cc_idx .. " out of range (item has " .. evt_cnt .. " channel events)"
2312
2786
  end
2313
2787
 
2314
2788
  reaper.Undo_BeginBlock()
2315
2789
  reaper.MIDI_DeleteCC(take, cc_idx)
2790
+ reaper.MarkTrackItemsDirty(track, item) -- else REAPER merges this into the previous MIDI undo point
2316
2791
  reaper.Undo_EndBlock("MCP: Delete MIDI CC " .. cc_idx, -1)
2317
2792
 
2318
2793
  reaper.UpdateArrange()
2319
2794
 
2320
- local _, _, new_cnt, _ = reaper.MIDI_CountEvts(take)
2321
- return { success = true, ccCount = new_cnt }
2795
+ local cc_cnt, new_evt_cnt = count_channel_events(take)
2796
+ return { success = true, ccCount = cc_cnt, channelEventCount = new_evt_cnt }
2322
2797
  end
2323
2798
 
2324
2799
  function handlers.get_midi_item_properties(params)
2325
2800
  local track, item, take, err = get_midi_take(params)
2326
2801
  if err then return nil, err end
2327
2802
 
2328
- local _, note_cnt, cc_cnt, _ = reaper.MIDI_CountEvts(take)
2803
+ local _, note_cnt, _, _ = reaper.MIDI_CountEvts(take)
2804
+ local cc_cnt, evt_cnt, evt_counts = count_channel_events(take)
2329
2805
 
2330
2806
  return {
2331
2807
  trackIndex = params.trackIndex,
@@ -2334,6 +2810,8 @@ function handlers.get_midi_item_properties(params)
2334
2810
  length = reaper.GetMediaItemInfo_Value(item, "D_LENGTH"),
2335
2811
  noteCount = note_cnt,
2336
2812
  ccCount = cc_cnt,
2813
+ channelEventCount = evt_cnt,
2814
+ channelEventCounts = evt_counts,
2337
2815
  muted = reaper.GetMediaItemInfo_Value(item, "B_MUTE") ~= 0,
2338
2816
  loopSource = reaper.GetMediaItemInfo_Value(item, "B_LOOPSRC") ~= 0,
2339
2817
  }
@@ -2426,8 +2904,9 @@ function handlers.get_media_item_properties(params)
2426
2904
  start_offset = reaper.GetMediaItemTakeInfo_Value(take, "D_STARTOFFS")
2427
2905
  local source = reaper.GetMediaItemTake_Source(take)
2428
2906
  if source then
2429
- local _, src_fn = reaper.GetMediaSourceFileName(source, "")
2430
- source_file = src_fn or ""
2907
+ -- REAPER returns just the file name here (older docs show retval, name).
2908
+ local a, b = reaper.GetMediaSourceFileName(source, "")
2909
+ source_file = type(b) == "string" and b or (type(a) == "string" and a or "")
2431
2910
  end
2432
2911
  end
2433
2912
 
@@ -2799,12 +3278,7 @@ function handlers.set_multiple_track_properties(params)
2799
3278
  local tracks_input = params.tracks
2800
3279
  if not tracks_input then return nil, "tracks array required" end
2801
3280
 
2802
- local tracks = nil
2803
- if type(tracks_input) == "string" then
2804
- tracks = json_decode(tracks_input)
2805
- elseif type(tracks_input) == "table" then
2806
- tracks = tracks_input
2807
- end
3281
+ local tracks = json_decode_array(tracks_input)
2808
3282
  if not tracks then return nil, "Failed to parse tracks array" end
2809
3283
 
2810
3284
  local updated = 0
@@ -2861,12 +3335,7 @@ function handlers.setup_fx_chain(params)
2861
3335
  local plugins_input = params.plugins
2862
3336
  if not plugins_input then return nil, "plugins array required" end
2863
3337
 
2864
- local plugins = nil
2865
- if type(plugins_input) == "string" then
2866
- plugins = json_decode(plugins_input)
2867
- elseif type(plugins_input) == "table" then
2868
- plugins = plugins_input
2869
- end
3338
+ local plugins = json_decode_array(plugins_input)
2870
3339
  if not plugins then return nil, "Failed to parse plugins array" end
2871
3340
 
2872
3341
  local track = reaper.GetTrack(0, track_idx)
@@ -2894,8 +3363,10 @@ function handlers.setup_fx_chain(params)
2894
3363
 
2895
3364
  -- Set initial parameters if provided
2896
3365
  local param_errors = {}
2897
- if plugin.parameters and type(plugin.parameters) == "table" then
2898
- for _, p in ipairs(plugin.parameters) do
3366
+ -- Nested arrays arrive as JSON text from the fallback decoder
3367
+ local plugin_params = plugin.parameters and json_decode_array(plugin.parameters)
3368
+ if plugin_params then
3369
+ for _, p in ipairs(plugin_params) do
2899
3370
  if p.index ~= nil and p.value ~= nil then
2900
3371
  local ok = reaper.TrackFX_SetParam(track, fx_idx, p.index, p.value)
2901
3372
  if not ok then
@@ -2938,12 +3409,7 @@ function handlers.set_multiple_fx_parameters(params)
2938
3409
  local updates_input = params.updates
2939
3410
  if not updates_input then return nil, "updates array required" end
2940
3411
 
2941
- local updates = nil
2942
- if type(updates_input) == "string" then
2943
- updates = json_decode(updates_input)
2944
- elseif type(updates_input) == "table" then
2945
- updates = updates_input
2946
- end
3412
+ local updates = json_decode_array(updates_input)
2947
3413
  if not updates then return nil, "Failed to parse updates array" end
2948
3414
 
2949
3415
  local updated = 0
@@ -2982,131 +3448,635 @@ function handlers.set_multiple_fx_parameters(params)
2982
3448
  end
2983
3449
 
2984
3450
  -- =============================================================================
2985
- -- Selection & Navigation
3451
+ -- Plan executor — one bridge call, one undo block, all-or-nothing
2986
3452
  -- =============================================================================
2987
3453
 
2988
- function handlers.get_selected_tracks(params)
2989
- local count = reaper.CountSelectedTracks(0)
2990
- local tracks = {}
2991
- for i = 0, count - 1 do
2992
- local track = reaper.GetSelectedTrack(0, i)
2993
- local idx = reaper.GetMediaTrackInfo_Value(track, "IP_TRACKNUMBER") - 1
2994
- local _, name = reaper.GetTrackName(track)
2995
- tracks[#tracks + 1] = { index = idx, name = name }
2996
- end
2997
- return { tracks = tracks, count = count }
3454
+ -- Custom: macros and scripts are only listed in reaper-kb.ini.
3455
+ local function read_kb_ini()
3456
+ local f = io.open(reaper.GetResourcePath() .. "/reaper-kb.ini", "r")
3457
+ if not f then return "" end
3458
+ local text = f:read("*a")
3459
+ f:close()
3460
+ return text
2998
3461
  end
2999
3462
 
3000
- function handlers.get_time_selection(params)
3001
- local start_pos, end_pos = reaper.GetSet_LoopTimeRange(false, false, 0, 0, false)
3002
- return {
3003
- start = start_pos,
3004
- ["end"] = end_pos,
3005
- length = end_pos - start_pos,
3006
- hasSelection = end_pos > start_pos,
3007
- }
3008
- end
3463
+ local plan_ctx = { applied = {} } -- idempotency keys of recently applied plans
3009
3464
 
3010
- function handlers.set_time_selection(params)
3011
- if params.start == nil then return nil, "start required" end
3012
- if params["end"] == nil then return nil, "end required" end
3013
- reaper.Undo_BeginBlock()
3014
- reaper.GetSet_LoopTimeRange(true, false, params.start, params["end"], false)
3015
- reaper.Undo_EndBlock("MCP: Set time selection", -1)
3016
- return { start = params.start, ["end"] = params["end"] }
3465
+ function handlers.run_plan(params)
3466
+ if not plan_lib_ok then
3467
+ return nil, "mcp_plan_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(plan_lib) .. ")"
3468
+ end
3469
+ plan_ctx.snapshot_save = function(name) return handlers.snapshot_save({ name = name }) end
3470
+ plan_ctx.actions = actions_lib_ok and actions_lib or nil
3471
+ plan_ctx.kb_text = actions_lib_ok and read_kb_ini() or nil
3472
+ return plan_lib.run_plan(reaper, params, plan_ctx)
3017
3473
  end
3018
3474
 
3019
3475
  -- =============================================================================
3020
- -- Markers & Regions
3476
+ -- Action proxy + selection setters (logic in mcp_actions_lib.lua)
3021
3477
  -- =============================================================================
3022
3478
 
3023
- function handlers.list_markers(params)
3024
- local _, num_markers, num_regions = reaper.CountProjectMarkers(0)
3025
- local total = num_markers + num_regions
3026
- local markers = {}
3027
- for i = 0, total - 1 do
3028
- local _, isrgn, pos, rgnend, name, markrgnindexnumber, color = reaper.EnumProjectMarkers3(0, i)
3029
- if not isrgn then
3030
- markers[#markers + 1] = {
3031
- index = markrgnindexnumber,
3032
- name = name,
3033
- position = pos,
3034
- color = color,
3035
- }
3036
- end
3479
+ local function with_actions_lib(fn)
3480
+ if not actions_lib_ok then
3481
+ return nil, "mcp_actions_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(actions_lib) .. ")"
3037
3482
  end
3038
- return { markers = markers, count = #markers }
3483
+ local ok, res, err = pcall(fn)
3484
+ if not ok then return nil, tostring(res) end
3485
+ return res, err
3039
3486
  end
3040
3487
 
3041
- function handlers.list_regions(params)
3042
- local _, num_markers, num_regions = reaper.CountProjectMarkers(0)
3043
- local total = num_markers + num_regions
3044
- local regions = {}
3045
- for i = 0, total - 1 do
3046
- local _, isrgn, pos, rgnend, name, markrgnindexnumber, color = reaper.EnumProjectMarkers3(0, i)
3047
- if isrgn then
3048
- regions[#regions + 1] = {
3049
- index = markrgnindexnumber,
3050
- name = name,
3051
- start = pos,
3052
- ["end"] = rgnend,
3053
- length = rgnend - pos,
3054
- color = color,
3055
- }
3056
- end
3057
- end
3058
- return { regions = regions, count = #regions }
3488
+ -- Arrays arrive as JSON strings: the flat command parser cannot read nested arrays.
3489
+ local function decode_list(value, field)
3490
+ if type(value) == "table" then return value end
3491
+ if type(value) ~= "string" or not plan_lib_ok then return nil, field .. " must be a JSON array" end
3492
+ local list, err = plan_lib.json_decode(value)
3493
+ if type(list) ~= "table" then return nil, field .. " is not a valid JSON array" .. (err and (" (" .. err .. ")") or "") end
3494
+ return list
3059
3495
  end
3060
3496
 
3061
- function handlers.add_marker(params)
3062
- if params.position == nil then return nil, "position required" end
3063
- local color = params.color or 0
3064
- local name = params.name or ""
3065
- reaper.Undo_BeginBlock()
3066
- local idx = reaper.AddProjectMarker2(0, false, params.position, 0, name, -1, color)
3067
- if idx < 0 then
3068
- reaper.Undo_EndBlock("MCP: Add marker (failed)", -1)
3069
- return nil, "Failed to add marker"
3070
- end
3071
- reaper.Undo_EndBlock("MCP: Add marker", -1)
3072
- return { index = idx, position = params.position, name = name }
3497
+ function handlers.search_actions(params)
3498
+ return with_actions_lib(function() return actions_lib.search(reaper, params, read_kb_ini()) end)
3073
3499
  end
3074
3500
 
3075
- function handlers.add_region(params)
3076
- if params.start == nil then return nil, "start required" end
3077
- if params["end"] == nil then return nil, "end required" end
3078
- local color = params.color or 0
3079
- local name = params.name or ""
3080
- reaper.Undo_BeginBlock()
3081
- local idx = reaper.AddProjectMarker2(0, true, params.start, params["end"], name, -1, color)
3082
- if idx < 0 then
3083
- reaper.Undo_EndBlock("MCP: Add region (failed)", -1)
3084
- return nil, "Failed to add region"
3085
- end
3086
- reaper.Undo_EndBlock("MCP: Add region", -1)
3087
- return { index = idx, start = params.start, ["end"] = params["end"], name = name }
3501
+ function handlers.get_action(params)
3502
+ return with_actions_lib(function() return actions_lib.get(reaper, params, read_kb_ini()) end)
3088
3503
  end
3089
3504
 
3090
- function handlers.delete_marker(params)
3091
- if params.markerIndex == nil then return nil, "markerIndex required" end
3092
- reaper.Undo_BeginBlock()
3093
- -- DeleteProjectMarker takes (proj, isrgn, markrgnindexnumber)
3094
- local deleted = reaper.DeleteProjectMarker(0, false, params.markerIndex, false)
3095
- if not deleted then
3096
- reaper.Undo_EndBlock("MCP: Delete marker (failed)", -1)
3097
- return nil, "Marker " .. params.markerIndex .. " not found"
3098
- end
3099
- reaper.Undo_EndBlock("MCP: Delete marker", -1)
3100
- return { success = true }
3505
+ function handlers.run_action(params)
3506
+ return with_actions_lib(function() return actions_lib.run(reaper, params, read_kb_ini()) end)
3101
3507
  end
3102
3508
 
3103
- function handlers.delete_region(params)
3104
- if params.regionIndex == nil then return nil, "regionIndex required" end
3105
- reaper.Undo_BeginBlock()
3106
- local deleted = reaper.DeleteProjectMarker(0, true, params.regionIndex, false)
3107
- if not deleted then
3108
- reaper.Undo_EndBlock("MCP: Delete region (failed)", -1)
3109
- return nil, "Region " .. params.regionIndex .. " not found"
3509
+ function handlers.set_selected_tracks(params)
3510
+ return with_actions_lib(function()
3511
+ if not plan_lib_ok then return nil, "mcp_plan_lib.lua is not installed next to mcp_bridge.lua" end
3512
+ local refs, err = decode_list(params.tracks, "tracks")
3513
+ if not refs then return nil, err end
3514
+ return actions_lib.set_selected_tracks(reaper, refs, params.mode, plan_lib.resolve_track)
3515
+ end)
3516
+ end
3517
+
3518
+ function handlers.set_selected_items(params)
3519
+ return with_actions_lib(function()
3520
+ local items, err = decode_list(params.items, "items")
3521
+ if not items then return nil, err end
3522
+ return actions_lib.set_selected_items(reaper, items, params.mode)
3523
+ end)
3524
+ end
3525
+
3526
+ -- =============================================================================
3527
+ -- Recording setup, fixed lanes and comping (logic in mcp_recording_lib.lua)
3528
+ -- =============================================================================
3529
+
3530
+ do -- locals scoped to this block: the main chunk is near Lua's 200-locals limit
3531
+ function ext.with_recording(fn)
3532
+ if ext.recording.load_error then
3533
+ return nil, "mcp_recording_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. ext.recording.load_error .. ")"
3534
+ end
3535
+ if not plan_lib_ok then return nil, "mcp_plan_lib.lua is not installed next to mcp_bridge.lua" end
3536
+ local ok, res, err = pcall(fn)
3537
+ if not ok then return nil, tostring(res) end
3538
+ return res, err
3539
+ end
3540
+
3541
+ -- Saved plugin state and extension commands (logic in mcp_fxstate_lib.lua / mcp_extensions_lib.lua).
3542
+ -- State travels as files in the bridge folder, never in the command JSON: it is megabytes.
3543
+ function ext.lib_call(key, fn, params, dir)
3544
+ local lib = ext[key]
3545
+ if lib.load_error then
3546
+ return nil, "mcp_" .. key .. "_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. lib.load_error .. ")"
3547
+ end
3548
+ if not plan_lib_ok then return nil, "mcp_plan_lib.lua is not installed next to mcp_bridge.lua" end
3549
+ local ctx = {
3550
+ plan = plan_lib, dir = dir or bridge_dir, read = read_file, fxstate = ext.fxstate,
3551
+ write = function(path, text)
3552
+ local f, err = io.open(path, "wb")
3553
+ if not f then return nil, err end
3554
+ f:write(text)
3555
+ f:close()
3556
+ return true
3557
+ end,
3558
+ }
3559
+ local ok, res, err = pcall(lib[fn], reaper, params, ctx)
3560
+ if not ok then return nil, "Handler error: " .. tostring(res) end
3561
+ return res, err
3562
+ end
3563
+ function handlers.get_fx_state(params) return ext.lib_call("fxstate", "get_fx_state", params) end
3564
+ function handlers.set_fx_state(params) return ext.lib_call("fxstate", "set_fx_state", params) end
3565
+ function handlers.extension_call(params) return ext.lib_call("extensions", "call", params, bridge_dir .. "extensions/") end
3566
+
3567
+ -- A JSON-encoded value (number, array or object) from the flat parser
3568
+ function ext.decode_json(value, field)
3569
+ if value == nil or type(value) == "table" or type(value) == "number" then return value end
3570
+ local v, err = plan_lib.json_decode(tostring(value))
3571
+ if v == nil then return nil, field .. " is not valid JSON" .. (err and (" (" .. err .. ")") or "") end
3572
+ return v
3573
+ end
3574
+
3575
+ function handlers.get_record_setup(params)
3576
+ return ext.with_recording(function()
3577
+ return ext.recording.get_record_setup(reaper, { all = params.all == true or params.all == "true" })
3578
+ end)
3579
+ end
3580
+
3581
+ function handlers.set_track_record(params)
3582
+ return ext.with_recording(function()
3583
+ local list, err = decode_list(params.tracks, "tracks")
3584
+ if not list then return nil, err end
3585
+ return ext.recording.set_track_record(reaper, list, plan_lib.resolve_track)
3586
+ end)
3587
+ end
3588
+
3589
+ function handlers.set_record_mode(params)
3590
+ return ext.with_recording(function() return ext.recording.set_record_mode(reaper, params) end)
3591
+ end
3592
+
3593
+ function handlers.get_track_lanes(params)
3594
+ return ext.with_recording(function() return ext.recording.get_track_lanes(reaper, params, plan_lib.resolve_track) end)
3595
+ end
3596
+
3597
+ function handlers.set_track_lanes(params)
3598
+ return ext.with_recording(function()
3599
+ local p = { track = params.track, laneCount = params.laneCount }
3600
+ if params.fixedLanes ~= nil then p.fixedLanes = params.fixedLanes == true or params.fixedLanes == "true" end
3601
+ local err
3602
+ p.names, err = ext.decode_json(params.names, "names")
3603
+ if err then return nil, err end
3604
+ p.play, err = ext.decode_json(params.play, "play")
3605
+ if err then return nil, err end
3606
+ return ext.recording.set_track_lanes(reaper, p, plan_lib.resolve_track)
3607
+ end)
3608
+ end
3609
+
3610
+ function handlers.comp_lanes(params)
3611
+ return ext.with_recording(function()
3612
+ local ranges, err = decode_list(params.ranges, "ranges")
3613
+ if not ranges then return nil, err end
3614
+ return ext.recording.comp_lanes(reaper, {
3615
+ track = params.track, ranges = ranges, crossfadeMs = params.crossfadeMs, name = params.name,
3616
+ }, plan_lib.resolve_track)
3617
+ end)
3618
+ end
3619
+ end
3620
+
3621
+ -- =============================================================================
3622
+ -- Track structure + routing setters (logic in mcp_tracks_lib.lua)
3623
+ -- =============================================================================
3624
+
3625
+ -- Resolve `params[field]` (index, name, GUID or "master") then run fn(track).
3626
+ local function with_tracks_lib(fn)
3627
+ if not tracks_lib_ok then
3628
+ return nil, "mcp_tracks_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(tracks_lib) .. ")"
3629
+ end
3630
+ if not plan_lib_ok then return nil, "mcp_plan_lib.lua is not installed next to mcp_bridge.lua" end
3631
+ local ok, res, err = pcall(fn)
3632
+ if not ok then return nil, tostring(res) end
3633
+ return res, err
3634
+ end
3635
+
3636
+ local function track_param(params, field)
3637
+ local ref = params[field]
3638
+ if ref == nil then return nil, field .. " required" end
3639
+ local res, err = plan_lib.resolve_track(reaper, ref, {})
3640
+ if not res then return nil, field .. ": " .. err end
3641
+ return res.track
3642
+ end
3643
+
3644
+ -- Run one structural edit in its own undo block, with a refreshed track list.
3645
+ local function undoable(label, fn)
3646
+ reaper.Undo_BeginBlock()
3647
+ reaper.PreventUIRefresh(1)
3648
+ local ok, res, err = pcall(fn)
3649
+ reaper.PreventUIRefresh(-1)
3650
+ reaper.Undo_EndBlock("MCP: " .. label, -1)
3651
+ reaper.TrackList_AdjustWindows(false)
3652
+ reaper.UpdateArrange()
3653
+ if not ok then return nil, tostring(res) end
3654
+ return res, err
3655
+ end
3656
+
3657
+ local function as_bool(v)
3658
+ if v == nil then return nil end
3659
+ return v == true or v == 1 or v == "true" or v == "1"
3660
+ end
3661
+
3662
+ function handlers.add_track(params)
3663
+ return with_tracks_lib(function()
3664
+ return undoable("Add track", function() return tracks_lib.add_track(reaper, params) end)
3665
+ end)
3666
+ end
3667
+
3668
+ function handlers.delete_track(params)
3669
+ return with_tracks_lib(function()
3670
+ local tr, err = track_param(params, "track")
3671
+ if not tr then return nil, err end
3672
+ return undoable("Delete track", function() return tracks_lib.delete_track(reaper, tr) end)
3673
+ end)
3674
+ end
3675
+
3676
+ function handlers.move_track(params)
3677
+ return with_tracks_lib(function()
3678
+ local tr, err = track_param(params, "track")
3679
+ if not tr then return nil, err end
3680
+ return undoable("Move track", function() return tracks_lib.move_track(reaper, tr, params.toIndex) end)
3681
+ end)
3682
+ end
3683
+
3684
+ function handlers.set_track_folder(params)
3685
+ return with_tracks_lib(function()
3686
+ local tr, err = track_param(params, "track")
3687
+ if not tr then return nil, err end
3688
+ return undoable("Set track folder", function() return tracks_lib.set_track_folder(reaper, tr, params) end)
3689
+ end)
3690
+ end
3691
+
3692
+ function handlers.set_track_info(params)
3693
+ return with_tracks_lib(function()
3694
+ local tr, err = track_param(params, "track")
3695
+ if not tr then return nil, err end
3696
+ params.masterSend = as_bool(params.masterSend)
3697
+ return undoable("Set track info", function() return tracks_lib.set_track_info(reaper, tr, params) end)
3698
+ end)
3699
+ end
3700
+
3701
+ function handlers.add_send(params)
3702
+ return with_tracks_lib(function()
3703
+ local src, e1 = track_param(params, "from")
3704
+ if not src then return nil, e1 end
3705
+ local dst, e2 = track_param(params, "to")
3706
+ if not dst then return nil, e2 end
3707
+ params.mute = as_bool(params.mute)
3708
+ return undoable("Add send", function() return tracks_lib.add_send(reaper, src, dst, params) end)
3709
+ end)
3710
+ end
3711
+
3712
+ function handlers.add_hardware_output(params)
3713
+ return with_tracks_lib(function()
3714
+ local tr, err = track_param(params, "track")
3715
+ if not tr then return nil, err end
3716
+ params.mute = as_bool(params.mute)
3717
+ params.mono = as_bool(params.mono)
3718
+ return undoable("Add hardware output", function() return tracks_lib.add_hardware_output(reaper, tr, params) end)
3719
+ end)
3720
+ end
3721
+
3722
+ function handlers.set_send(params)
3723
+ return with_tracks_lib(function()
3724
+ local tr, err = track_param(params, "track")
3725
+ if not tr then return nil, err end
3726
+ params.mute = as_bool(params.mute)
3727
+ params.mono = as_bool(params.mono)
3728
+ return undoable("Edit send", function() return tracks_lib.set_send(reaper, tr, params) end)
3729
+ end)
3730
+ end
3731
+
3732
+ function handlers.remove_send(params)
3733
+ return with_tracks_lib(function()
3734
+ local tr, err = track_param(params, "track")
3735
+ if not tr then return nil, err end
3736
+ return undoable("Remove send", function() return tracks_lib.remove_send(reaper, tr, params) end)
3737
+ end)
3738
+ end
3739
+
3740
+ function handlers.set_track_group(params)
3741
+ return with_tracks_lib(function()
3742
+ local tr, err = track_param(params, "track")
3743
+ if not tr then return nil, err end
3744
+ local roles, rerr = decode_list(params.roles, "roles")
3745
+ if not roles then return nil, rerr end
3746
+ params.roles = roles
3747
+ params.enable = as_bool(params.enable)
3748
+ return undoable("Set track group", function() return tracks_lib.set_track_group(reaper, tr, params) end)
3749
+ end)
3750
+ end
3751
+
3752
+ -- get_track_routing keeps its fields and gains what the setters change:
3753
+ -- send index/mode/channels, hardware outputs, master send, channel count.
3754
+ do
3755
+ local base = handlers.get_track_routing
3756
+ handlers.get_track_routing = function(params)
3757
+ local res, err = base(params)
3758
+ if not res or not tracks_lib_ok then return res, err end
3759
+ local tr = reaper.GetTrack(0, params.trackIndex)
3760
+ local ok, extra = pcall(tracks_lib.routing, reaper, tr)
3761
+ if not ok then return res end
3762
+ for k, s in ipairs(res.sends or {}) do
3763
+ local x = extra.sends[k]
3764
+ if x then s.index, s.mode, s.srcChannel, s.destChannel = x.index, x.mode, x.srcChannel, x.destChannel end
3765
+ end
3766
+ for k, r in ipairs(res.receives or {}) do
3767
+ local x = extra.receives[k]
3768
+ if x then r.index, r.mode, r.srcChannel, r.destChannel = x.index, x.mode, x.srcChannel, x.destChannel end
3769
+ end
3770
+ res.hardwareOutputs = extra.hardwareOutputs
3771
+ res.masterSend = extra.masterSend
3772
+ res.channels = extra.channels
3773
+ return res
3774
+ end
3775
+ end
3776
+
3777
+ -- =============================================================================
3778
+ -- Project ops, tempo map and marker editing (logic in mcp_project_lib.lua)
3779
+ -- =============================================================================
3780
+
3781
+ local function with_project_lib(fn)
3782
+ if not project_lib_ok then
3783
+ return nil, "mcp_project_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(project_lib) .. ")"
3784
+ end
3785
+ local ok, res, err = pcall(fn)
3786
+ if not ok then return nil, tostring(res) end
3787
+ return res, err
3788
+ end
3789
+
3790
+ -- Real file system for save/render checks.
3791
+ local project_fs = {
3792
+ exists = function(path)
3793
+ local f = io.open(path, "rb")
3794
+ if f then f:close() return true end
3795
+ return false
3796
+ end,
3797
+ dir_exists = function(path)
3798
+ return os.rename(path, path) == true
3799
+ end,
3800
+ remove = function(path) os.remove(path) end,
3801
+ }
3802
+
3803
+ function handlers.undo_project(params)
3804
+ return with_project_lib(function() return project_lib.undo(reaper) end)
3805
+ end
3806
+
3807
+ function handlers.redo_project(params)
3808
+ return with_project_lib(function() return project_lib.redo(reaper) end)
3809
+ end
3810
+
3811
+ function handlers.save_project(params)
3812
+ return with_project_lib(function() return project_lib.save(reaper, params, project_fs) end)
3813
+ end
3814
+
3815
+ function handlers.get_render_settings(params)
3816
+ return with_project_lib(function() return project_lib.get_render(reaper) end)
3817
+ end
3818
+
3819
+ function handlers.set_render_settings(params)
3820
+ return with_project_lib(function()
3821
+ local p, err = project_lib.decode_json_fields(params, plan_lib_ok and plan_lib.json_decode or function() return nil end)
3822
+ if not p then return nil, err end
3823
+ return project_lib.set_render(reaper, p, plan_lib_ok and plan_lib.resolve_track or nil)
3824
+ end)
3825
+ end
3826
+
3827
+ function handlers.render_project(params)
3828
+ return with_project_lib(function()
3829
+ local p, err = project_lib.decode_json_fields(params, plan_lib_ok and plan_lib.json_decode or function() return nil end)
3830
+ if not p then return nil, err end
3831
+ return project_lib.render(reaper, p, project_fs, plan_lib_ok and plan_lib.resolve_track or nil)
3832
+ end)
3833
+ end
3834
+
3835
+ function handlers.render_versions(params)
3836
+ if not plan_lib_ok then return nil, "mcp_plan_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup`" end
3837
+ return with_project_lib(function()
3838
+ local p, err = project_lib.decode_json_fields(params, plan_lib.json_decode)
3839
+ if not p then return nil, err end
3840
+ return project_lib.render_versions(reaper, p, project_fs, plan_lib.resolve_track)
3841
+ end)
3842
+ end
3843
+
3844
+ function handlers.get_project_settings(params)
3845
+ return with_project_lib(function() return project_lib.get_settings(reaper) end)
3846
+ end
3847
+
3848
+ function handlers.set_project_settings(params)
3849
+ return with_project_lib(function() return project_lib.set_settings(reaper, params) end)
3850
+ end
3851
+
3852
+ function handlers.add_tempo_marker(params)
3853
+ return with_project_lib(function() return project_lib.add_tempo_marker(reaper, params) end)
3854
+ end
3855
+
3856
+ function handlers.edit_tempo_marker(params)
3857
+ return with_project_lib(function() return project_lib.edit_tempo_marker(reaper, params) end)
3858
+ end
3859
+
3860
+ function handlers.delete_tempo_marker(params)
3861
+ return with_project_lib(function() return project_lib.delete_tempo_marker(reaper, params) end)
3862
+ end
3863
+
3864
+ function handlers.set_project_tempo(params)
3865
+ return with_project_lib(function() return project_lib.set_tempo(reaper, params) end)
3866
+ end
3867
+
3868
+ function handlers.edit_marker(params)
3869
+ return with_project_lib(function() return project_lib.edit_marker(reaper, params) end)
3870
+ end
3871
+
3872
+ function handlers.edit_region(params)
3873
+ return with_project_lib(function() return project_lib.edit_region(reaper, params) end)
3874
+ end
3875
+
3876
+ -- =============================================================================
3877
+ -- Items, takes, take FX and FX housekeeping (logic in mcp_items_lib.lua)
3878
+ -- =============================================================================
3879
+
3880
+ local items_fs = {
3881
+ exists = function(path) return reaper.file_exists(path) end,
3882
+ read = function(path) return read_file(path) end,
3883
+ write = function(path, text)
3884
+ local f, err = io.open(path, "w")
3885
+ if not f then return nil, err end
3886
+ f:write(text)
3887
+ f:close()
3888
+ return true
3889
+ end,
3890
+ mkdir = function(dir) reaper.RecursiveCreateDirectory(dir, 0) end,
3891
+ list = function(dir)
3892
+ local files, dirs = {}, {}
3893
+ -- Index -1 drops REAPER's cached listing so new files show up.
3894
+ reaper.EnumerateFiles(dir, -1)
3895
+ reaper.EnumerateSubdirectories(dir, -1)
3896
+ local i = 0
3897
+ while true do
3898
+ local f = reaper.EnumerateFiles(dir, i)
3899
+ if not f then break end
3900
+ files[#files + 1] = f
3901
+ i = i + 1
3902
+ end
3903
+ i = 0
3904
+ while true do
3905
+ local d = reaper.EnumerateSubdirectories(dir, i)
3906
+ if not d then break end
3907
+ dirs[#dirs + 1] = d
3908
+ i = i + 1
3909
+ end
3910
+ return files, dirs
3911
+ end,
3912
+ }
3913
+
3914
+ local function with_items_lib(name, params)
3915
+ if not items_lib_ok then
3916
+ return nil, "mcp_items_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(items_lib) .. ")"
3917
+ end
3918
+ if not plan_lib_ok then return nil, "mcp_plan_lib.lua is not installed next to mcp_bridge.lua" end
3919
+ local ok, res, err = pcall(items_lib[name], reaper, params, { plan = plan_lib, fs = items_fs })
3920
+ if not ok then return nil, tostring(res) end
3921
+ return res, err
3922
+ end
3923
+
3924
+ function handlers.insert_audio_file(params) return with_items_lib("insert_audio_file", params) end
3925
+ function handlers.insert_stem_tracks(params)
3926
+ -- files travels as a JSON string (the bridge decodes only the top level)
3927
+ if type(params.files) == "string" and plan_lib_ok then
3928
+ local ok, files = pcall(plan_lib.json_decode, params.files)
3929
+ params.files = ok and files or nil
3930
+ end
3931
+ return with_items_lib("insert_stems", params)
3932
+ end
3933
+ function handlers.list_takes(params) return with_items_lib("list_takes", params) end
3934
+ function handlers.set_active_take(params) return with_items_lib("set_active_take", params) end
3935
+ function handlers.get_take_fx(params) return with_items_lib("get_take_fx", params) end
3936
+ function handlers.add_take_fx(params) return with_items_lib("add_take_fx", params) end
3937
+ function handlers.remove_take_fx(params) return with_items_lib("remove_take_fx", params) end
3938
+ function handlers.set_take_fx_parameter(params) return with_items_lib("set_take_fx_parameter", params) end
3939
+ function handlers.get_item_appearance(params) return with_items_lib("get_item_appearance", params) end
3940
+ function handlers.set_item_color(params) return with_items_lib("set_item_color", params) end
3941
+ function handlers.set_item_fades(params) return with_items_lib("set_item_fades", params) end
3942
+ function handlers.move_fx(params) return with_items_lib("move_fx", params) end
3943
+ function handlers.copy_fx(params) return with_items_lib("copy_fx", params) end
3944
+ function handlers.rename_fx(params) return with_items_lib("rename_fx", params) end
3945
+ function handlers.save_fx_chain(params) return with_items_lib("save_fx_chain", params) end
3946
+ function handlers.load_fx_chain(params) return with_items_lib("load_fx_chain", params) end
3947
+ function handlers.list_fx_chains(params) return with_items_lib("list_fx_chains", params) end
3948
+ function handlers.save_track_template(params) return with_items_lib("save_track_template", params) end
3949
+ function handlers.insert_track_template(params) return with_items_lib("insert_track_template", params) end
3950
+ function handlers.list_track_templates(params) return with_items_lib("list_track_templates", params) end
3951
+
3952
+ -- =============================================================================
3953
+ -- Selection & Navigation
3954
+ -- =============================================================================
3955
+
3956
+ function handlers.get_selected_tracks(params)
3957
+ -- The *2 variants include the master (IP_TRACKNUMBER -1 -> index -1).
3958
+ local count = reaper.CountSelectedTracks2(0, true)
3959
+ local tracks = {}
3960
+ for i = 0, count - 1 do
3961
+ local track = reaper.GetSelectedTrack2(0, i, true)
3962
+ local num = reaper.GetMediaTrackInfo_Value(track, "IP_TRACKNUMBER")
3963
+ local idx = num == -1 and -1 or num - 1
3964
+ local _, name = reaper.GetTrackName(track)
3965
+ tracks[#tracks + 1] = { index = idx, name = name }
3966
+ end
3967
+ return { tracks = tracks, count = count }
3968
+ end
3969
+
3970
+ function handlers.get_time_selection(params)
3971
+ local start_pos, end_pos = reaper.GetSet_LoopTimeRange(false, false, 0, 0, false)
3972
+ return {
3973
+ start = start_pos,
3974
+ ["end"] = end_pos,
3975
+ length = end_pos - start_pos,
3976
+ hasSelection = end_pos > start_pos,
3977
+ }
3978
+ end
3979
+
3980
+ function handlers.set_time_selection(params)
3981
+ if params.start == nil then return nil, "start required" end
3982
+ if params["end"] == nil then return nil, "end required" end
3983
+ reaper.Undo_BeginBlock()
3984
+ reaper.GetSet_LoopTimeRange(true, false, params.start, params["end"], false)
3985
+ reaper.Undo_EndBlock("MCP: Set time selection", -1)
3986
+ return { start = params.start, ["end"] = params["end"] }
3987
+ end
3988
+
3989
+ -- =============================================================================
3990
+ -- Markers & Regions
3991
+ -- =============================================================================
3992
+
3993
+ function handlers.list_markers(params)
3994
+ local _, num_markers, num_regions = reaper.CountProjectMarkers(0)
3995
+ local total = num_markers + num_regions
3996
+ local markers = {}
3997
+ for i = 0, total - 1 do
3998
+ local _, isrgn, pos, rgnend, name, markrgnindexnumber, color = reaper.EnumProjectMarkers3(0, i)
3999
+ if not isrgn then
4000
+ markers[#markers + 1] = {
4001
+ index = markrgnindexnumber,
4002
+ name = name,
4003
+ position = pos,
4004
+ color = color,
4005
+ }
4006
+ end
4007
+ end
4008
+ return { markers = markers, count = #markers }
4009
+ end
4010
+
4011
+ function handlers.list_regions(params)
4012
+ local _, num_markers, num_regions = reaper.CountProjectMarkers(0)
4013
+ local total = num_markers + num_regions
4014
+ local regions = {}
4015
+ for i = 0, total - 1 do
4016
+ local _, isrgn, pos, rgnend, name, markrgnindexnumber, color = reaper.EnumProjectMarkers3(0, i)
4017
+ if isrgn then
4018
+ regions[#regions + 1] = {
4019
+ index = markrgnindexnumber,
4020
+ name = name,
4021
+ start = pos,
4022
+ ["end"] = rgnend,
4023
+ length = rgnend - pos,
4024
+ color = color,
4025
+ }
4026
+ end
4027
+ end
4028
+ return { regions = regions, count = #regions }
4029
+ end
4030
+
4031
+ function handlers.add_marker(params)
4032
+ if params.position == nil then return nil, "position required" end
4033
+ local color = params.color or 0
4034
+ local name = params.name or ""
4035
+ reaper.Undo_BeginBlock()
4036
+ local idx = reaper.AddProjectMarker2(0, false, params.position, 0, name, -1, color)
4037
+ if idx < 0 then
4038
+ reaper.Undo_EndBlock("MCP: Add marker (failed)", -1)
4039
+ return nil, "Failed to add marker"
4040
+ end
4041
+ reaper.Undo_EndBlock("MCP: Add marker", -1)
4042
+ return { index = idx, position = params.position, name = name }
4043
+ end
4044
+
4045
+ function handlers.add_region(params)
4046
+ if params.start == nil then return nil, "start required" end
4047
+ if params["end"] == nil then return nil, "end required" end
4048
+ local color = params.color or 0
4049
+ local name = params.name or ""
4050
+ reaper.Undo_BeginBlock()
4051
+ local idx = reaper.AddProjectMarker2(0, true, params.start, params["end"], name, -1, color)
4052
+ if idx < 0 then
4053
+ reaper.Undo_EndBlock("MCP: Add region (failed)", -1)
4054
+ return nil, "Failed to add region"
4055
+ end
4056
+ reaper.Undo_EndBlock("MCP: Add region", -1)
4057
+ return { index = idx, start = params.start, ["end"] = params["end"], name = name }
4058
+ end
4059
+
4060
+ function handlers.delete_marker(params)
4061
+ if params.markerIndex == nil then return nil, "markerIndex required" end
4062
+ reaper.Undo_BeginBlock()
4063
+ -- DeleteProjectMarker takes (proj, markrgnindexnumber, isrgn)
4064
+ local deleted = reaper.DeleteProjectMarker(0, params.markerIndex, false)
4065
+ if not deleted then
4066
+ reaper.Undo_EndBlock("MCP: Delete marker (failed)", -1)
4067
+ return nil, "Marker " .. params.markerIndex .. " not found"
4068
+ end
4069
+ reaper.Undo_EndBlock("MCP: Delete marker", -1)
4070
+ return { success = true }
4071
+ end
4072
+
4073
+ function handlers.delete_region(params)
4074
+ if params.regionIndex == nil then return nil, "regionIndex required" end
4075
+ reaper.Undo_BeginBlock()
4076
+ local deleted = reaper.DeleteProjectMarker(0, params.regionIndex, true)
4077
+ if not deleted then
4078
+ reaper.Undo_EndBlock("MCP: Delete region (failed)", -1)
4079
+ return nil, "Region " .. params.regionIndex .. " not found"
3110
4080
  end
3111
4081
  reaper.Undo_EndBlock("MCP: Delete region", -1)
3112
4082
  return { success = true }
@@ -3133,7 +4103,7 @@ function handlers.get_tempo_map(params)
3133
4103
  -- If no markers, return the project tempo as the single entry
3134
4104
  if count == 0 then
3135
4105
  local bpm = reaper.Master_GetTempo()
3136
- local _, num, denom = reaper.TimeMap_GetTimeSigAtTime(0, 0)
4106
+ local num, denom = reaper.TimeMap_GetTimeSigAtTime(0, 0)
3137
4107
  markers[1] = {
3138
4108
  index = 0,
3139
4109
  position = 0,
@@ -3150,212 +4120,105 @@ end
3150
4120
  -- Envelopes / Automation
3151
4121
  -- =============================================================================
3152
4122
 
3153
- function handlers.get_track_envelopes(params)
3154
- local track = reaper.GetTrack(0, params.trackIndex)
3155
- if not track then return nil, "Track " .. params.trackIndex .. " not found" end
3156
- local count = reaper.CountTrackEnvelopes(track)
3157
- local envelopes = {}
3158
- for i = 0, count - 1 do
3159
- local env = reaper.GetTrackEnvelope(track, i)
3160
- local _, name = reaper.GetEnvelopeName(env)
3161
- local point_count = reaper.CountEnvelopePoints(env)
3162
- -- Get envelope state info (SWS extension provides BR_Env*, fallback if unavailable)
3163
- local active, visible, armed = true, true, false
3164
- if reaper.BR_EnvAlloc then
3165
- local br_env = reaper.BR_EnvAlloc(env, false)
3166
- active, visible, armed = reaper.BR_EnvGetProperties(br_env)
3167
- reaper.BR_EnvFree(br_env, false)
3168
- end
3169
- envelopes[#envelopes + 1] = {
3170
- index = i,
3171
- name = name,
3172
- pointCount = point_count,
3173
- active = active,
3174
- visible = visible,
3175
- armed = armed,
3176
- }
4123
+ -- Points, creation and automation items: logic in mcp_envelopes_lib.lua. One table, not
4124
+ -- separate locals: the bridge's main chunk is at Lua's 200-locals limit.
4125
+ local env_ext = {}
4126
+ env_ext.ok, env_ext.lib = pcall(dofile, script_dir .. "mcp_envelopes_lib.lua")
4127
+
4128
+ function env_ext.run(name, params, ...)
4129
+ if not env_ext.ok then
4130
+ return nil, "mcp_envelopes_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(env_ext.lib) .. ")"
3177
4131
  end
3178
- return { trackIndex = params.trackIndex, envelopes = envelopes, count = count }
4132
+ local ok, res, err = pcall(env_ext.lib[name], reaper, params, ...)
4133
+ if not ok then return nil, "Handler error: " .. tostring(res) end
4134
+ return res, err
3179
4135
  end
3180
4136
 
3181
- function handlers.get_envelope_points(params)
3182
- local track = reaper.GetTrack(0, params.trackIndex)
3183
- if not track then return nil, "Track " .. params.trackIndex .. " not found" end
3184
- local env_count = reaper.CountTrackEnvelopes(track)
3185
- if params.envelopeIndex >= env_count then
3186
- return nil, "Envelope index " .. params.envelopeIndex .. " out of range (track has " .. env_count .. " envelopes)"
3187
- end
3188
- local env = reaper.GetTrackEnvelope(track, params.envelopeIndex)
3189
- local total = reaper.CountEnvelopePoints(env)
3190
- local offset = params.offset or 0
3191
- local limit = params.limit or total
3192
- local points = {}
3193
- local end_idx = math.min(offset + limit, total)
3194
- for i = offset, end_idx - 1 do
3195
- local _, time, value, shape, tension, selected = reaper.GetEnvelopePoint(env, i)
3196
- points[#points + 1] = {
3197
- index = i,
3198
- time = time,
3199
- value = value,
3200
- shape = shape,
3201
- tension = tension,
3202
- selected = selected,
3203
- }
3204
- end
3205
- return {
3206
- trackIndex = params.trackIndex,
3207
- envelopeIndex = params.envelopeIndex,
3208
- points = points,
3209
- total = total,
3210
- offset = offset,
3211
- hasMore = end_idx < total,
3212
- }
4137
+ -- A JSON array param arrives as a string (or, from some clients, already decoded)
4138
+ function env_ext.list(v)
4139
+ if type(v) == "table" then return v end
4140
+ if type(v) ~= "string" then return nil end
4141
+ return json_decode_array(v)
3213
4142
  end
3214
4143
 
4144
+ function handlers.get_track_envelopes(params) return env_ext.run("list_envelopes", params) end
4145
+
4146
+ function handlers.get_envelope_points(params) return env_ext.run("get_points", params) end
4147
+
3215
4148
  function handlers.insert_envelope_point(params)
3216
- local track = reaper.GetTrack(0, params.trackIndex)
3217
- if not track then return nil, "Track " .. params.trackIndex .. " not found" end
3218
- local env_count = reaper.CountTrackEnvelopes(track)
3219
- if params.envelopeIndex >= env_count then
3220
- return nil, "Envelope index " .. params.envelopeIndex .. " out of range (track has " .. env_count .. " envelopes)"
3221
- end
3222
- local env = reaper.GetTrackEnvelope(track, params.envelopeIndex)
3223
- local shape = params.shape or 0
3224
- local tension = params.tension or 0
3225
- reaper.Undo_BeginBlock()
3226
- reaper.InsertEnvelopePoint(env, params.time, params.value, shape, tension, false, true)
3227
- reaper.Envelope_SortPoints(env)
3228
- reaper.Undo_EndBlock("MCP: Insert envelope point", -1)
3229
- local total = reaper.CountEnvelopePoints(env)
3230
- return {
3231
- trackIndex = params.trackIndex,
3232
- envelopeIndex = params.envelopeIndex,
3233
- time = params.time,
3234
- value = params.value,
3235
- shape = shape,
3236
- tension = tension,
3237
- totalPoints = total,
3238
- }
4149
+ return env_ext.run("insert_points", params, {
4150
+ { time = params.time, value = params.value, db = params.db, shape = params.shape, tension = params.tension },
4151
+ })
3239
4152
  end
3240
4153
 
3241
- function handlers.delete_envelope_point(params)
3242
- local track = reaper.GetTrack(0, params.trackIndex)
3243
- if not track then return nil, "Track " .. params.trackIndex .. " not found" end
3244
- local env_count = reaper.CountTrackEnvelopes(track)
3245
- if params.envelopeIndex >= env_count then
3246
- return nil, "Envelope index " .. params.envelopeIndex .. " out of range (track has " .. env_count .. " envelopes)"
3247
- end
3248
- local env = reaper.GetTrackEnvelope(track, params.envelopeIndex)
3249
- local total = reaper.CountEnvelopePoints(env)
3250
- if params.pointIndex >= total then
3251
- return nil, "Point index " .. params.pointIndex .. " out of range (envelope has " .. total .. " points)"
3252
- end
3253
- reaper.Undo_BeginBlock()
3254
- reaper.DeleteEnvelopePointRange(env, params.pointIndex, params.pointIndex + 1)
3255
- reaper.Undo_EndBlock("MCP: Delete envelope point", -1)
3256
- return { success = true, totalPoints = reaper.CountEnvelopePoints(env) }
4154
+ function handlers.insert_envelope_points(params)
4155
+ local points = env_ext.list(params.points)
4156
+ if not points then return nil, "Failed to parse points JSON" end
4157
+ return env_ext.run("insert_points", params, points)
3257
4158
  end
3258
4159
 
3259
- function handlers.create_track_envelope(params)
3260
- local track = reaper.GetTrack(0, params.trackIndex)
3261
- if not track then return nil, "Track " .. params.trackIndex .. " not found" end
4160
+ function handlers.edit_envelope_points(params)
4161
+ local points = env_ext.list(params.points)
4162
+ if not points then return nil, "Failed to parse points JSON" end
4163
+ return env_ext.run("edit_points", params, points)
4164
+ end
3262
4165
 
3263
- reaper.Undo_BeginBlock()
4166
+ function handlers.delete_envelope_point(params) return env_ext.run("delete_point", params) end
3264
4167
 
3265
- local env = nil
3266
- local env_name = nil
3267
-
3268
- if params.fxIndex ~= nil then
3269
- -- FX parameter envelope
3270
- if params.paramIndex == nil then
3271
- return nil, "paramIndex is required when fxIndex is provided"
3272
- end
3273
- local fx_count = reaper.TrackFX_GetNumParms(track)
3274
- if fx_count == 0 then
3275
- return nil, "Track " .. params.trackIndex .. " has no FX"
3276
- end
3277
- -- create=true to create the envelope if it doesn't exist
3278
- env = reaper.GetFXEnvelope(track, params.fxIndex, params.paramIndex, true)
3279
- if not env then
3280
- return nil, "Failed to create FX parameter envelope (fxIndex=" .. params.fxIndex .. ", paramIndex=" .. params.paramIndex .. ")"
3281
- end
3282
- local _, pname = reaper.TrackFX_GetParamName(track, params.fxIndex, params.paramIndex)
3283
- local _, fname = reaper.TrackFX_GetFXName(track, params.fxIndex)
3284
- env_name = fname .. " / " .. pname
3285
- elseif params.envelopeName then
3286
- -- Built-in envelope: use GetTrackEnvelopeByName + show via state chunk
3287
- env = reaper.GetTrackEnvelopeByName(track, params.envelopeName)
3288
- if not env then
3289
- -- Some envelopes need to be activated first via the track state chunk
3290
- -- Toggle the envelope visibility via action or chunk editing
3291
- local env_map = {
3292
- ["Volume"] = "VOLENV",
3293
- ["Pan"] = "PANENV",
3294
- ["Mute"] = "MUTEENV",
3295
- ["Width"] = "WIDTHENV",
3296
- ["Trim Volume"] = "VOLENV2",
3297
- }
3298
- local chunk_key = env_map[params.envelopeName]
3299
- if not chunk_key then
3300
- return nil, "Unknown envelope name: " .. params.envelopeName .. ". Use Volume, Pan, Mute, Width, or Trim Volume"
3301
- end
3302
- -- Get track chunk, insert envelope chunk if missing
3303
- -- Use anchored pattern "<VOLENV\n" to avoid matching VOLENV2 (Trim Volume)
3304
- local _, chunk = reaper.GetTrackStateChunk(track, "", false)
3305
- if not chunk:find("<" .. chunk_key .. "\n") then
3306
- -- Insert a minimal envelope chunk before the closing >
3307
- local env_chunk = "\n<" .. chunk_key .. "\nACT 1 -1\nVIS 1 1 1\nLANEHEIGHT 0 0\nARM 0\nDEFSHAPE 0 -1 -1\n>\n"
3308
- -- Use position capture to find the last ">" (closing the <TRACK block).
3309
- -- We cannot use "\n>$" because GetTrackStateChunk may include a trailing newline.
3310
- local last_gt = chunk:match(".*()>")
3311
- if last_gt then
3312
- chunk = chunk:sub(1, last_gt - 1) .. env_chunk .. chunk:sub(last_gt)
3313
- end
3314
- reaper.SetTrackStateChunk(track, chunk, false)
3315
- else
3316
- -- Envelope exists in chunk but may be hidden; make it visible
3317
- chunk = chunk:gsub("(<" .. chunk_key .. "[^\n]*\n)ACT 0", "%1ACT 1")
3318
- chunk = chunk:gsub("(<" .. chunk_key .. "[^\n]*\n[^\n]*\n)VIS 0", "%1VIS 1")
3319
- reaper.SetTrackStateChunk(track, chunk, false)
3320
- end
3321
- env = reaper.GetTrackEnvelopeByName(track, params.envelopeName)
3322
- if not env then
3323
- return nil, "Failed to create envelope: " .. params.envelopeName
3324
- end
3325
- else
3326
- -- Envelope exists, ensure it's visible and active
3327
- local _, chunk = reaper.GetEnvelopeStateChunk(env, "", false)
3328
- chunk = chunk:gsub("ACT 0", "ACT 1")
3329
- chunk = chunk:gsub("VIS 0", "VIS 1")
3330
- reaper.SetEnvelopeStateChunk(env, chunk, false)
3331
- end
3332
- env_name = params.envelopeName
3333
- else
3334
- return nil, "Must provide either envelopeName or fxIndex+paramIndex"
3335
- end
4168
+ function handlers.create_track_envelope(params) return env_ext.run("create", params) end
3336
4169
 
3337
- -- Find the index of the newly created envelope
3338
- local env_count = reaper.CountTrackEnvelopes(track)
3339
- local env_index = -1
3340
- for i = 0, env_count - 1 do
3341
- if reaper.GetTrackEnvelope(track, i) == env then
3342
- env_index = i
3343
- break
3344
- end
4170
+ -- Mixing depth (mcp_mixing_lib.lua): automation modes, FX parameter modulation, pins,
4171
+ -- monitoring FX, freeze / render in place, stretch markers. Nested params arrive as JSON strings.
4172
+ -- Lib wrappers added since the main chunk reached the 200-locals limit live here, one field each
4173
+ ext.mix = {}
4174
+ ext.mix.ok, ext.mix.lib = pcall(dofile, script_dir .. "mcp_mixing_lib.lua")
4175
+
4176
+ function ext.mix.decode(v)
4177
+ if type(v) ~= "string" then return v end
4178
+ local ok, res = pcall(plan_lib.json_decode, v)
4179
+ if ok then return res end
4180
+ return nil
4181
+ end
4182
+
4183
+ function ext.mix.run(name, ...)
4184
+ if not ext.mix.ok then
4185
+ return nil, "mcp_mixing_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(ext.mix.lib) .. ")"
3345
4186
  end
4187
+ local ok, res, err = pcall(ext.mix.lib[name], reaper, ...)
4188
+ if not ok then return nil, "Handler error: " .. tostring(res) end
4189
+ return res, err
4190
+ end
3346
4191
 
3347
- reaper.Undo_EndBlock("MCP: Create envelope '" .. (env_name or "") .. "'", -1)
3348
- reaper.MarkProjectDirty(0)
3349
- reaper.TrackList_AdjustWindows(false)
3350
- reaper.UpdateArrange()
4192
+ function ext.mix.tracks(params)
4193
+ params.tracks = ext.mix.decode(params.tracks)
4194
+ return params
4195
+ end
3351
4196
 
3352
- return {
3353
- trackIndex = params.trackIndex,
3354
- envelopeIndex = env_index,
3355
- name = env_name,
3356
- pointCount = reaper.CountEnvelopePoints(env),
3357
- }
4197
+ function handlers.set_automation_mode(params)
4198
+ return ext.mix.run("set_automation_mode", plan_lib.resolve_track, ext.mix.tracks(params))
4199
+ end
4200
+ function handlers.get_fx_param_modulation(params) return ext.mix.run("get_modulation", params) end
4201
+ function handlers.set_fx_param_modulation(params)
4202
+ return ext.mix.run("set_modulation", params, {
4203
+ enabled = params.enabled, baseline = params.baseline,
4204
+ lfo = ext.mix.decode(params.lfo), audio = ext.mix.decode(params.audio), link = ext.mix.decode(params.link),
4205
+ })
3358
4206
  end
4207
+ function handlers.get_fx_pins(params) return ext.mix.run("get_pins", params) end
4208
+ function handlers.set_fx_pins(params)
4209
+ return ext.mix.run("set_pins", params, ext.mix.decode(params.inputs), ext.mix.decode(params.outputs))
4210
+ end
4211
+ function handlers.list_monitoring_fx() return ext.mix.run("list_monitoring_fx") end
4212
+ function handlers.add_monitoring_fx(params) return ext.mix.run("add_monitoring_fx", params, ext.mix.decode(params.params)) end
4213
+ function handlers.set_monitoring_fx(params) return ext.mix.run("set_monitoring_fx", params, ext.mix.decode(params.params)) end
4214
+ function handlers.remove_monitoring_fx(params) return ext.mix.run("remove_monitoring_fx", params) end
4215
+ function handlers.freeze_tracks(params) return ext.mix.run("freeze_tracks", plan_lib.resolve_track, ext.mix.tracks(params)) end
4216
+ function handlers.unfreeze_tracks(params) return ext.mix.run("unfreeze_tracks", plan_lib.resolve_track, ext.mix.tracks(params)) end
4217
+ function handlers.apply_fx_to_items(params) return ext.mix.run("apply_fx_to_items", params, ext.mix.decode(params.items)) end
4218
+ function handlers.add_stretch_markers(params) return ext.mix.run("add_stretch_markers", params, ext.mix.decode(params.markers)) end
4219
+ function handlers.quantize_audio(params) return ext.mix.run("quantize_stretch", params, ext.mix.decode(params.hits)) end
4220
+ function handlers.edit_stretch_markers(params) return ext.mix.run("edit_stretch_markers", params, ext.mix.decode(params.edits)) end
4221
+ function handlers.align_audio_to_guide(params) return ext.mix.run("align_stretch", params, ext.mix.decode(params.pairs)) end
3359
4222
 
3360
4223
  function handlers.set_envelope_properties(params)
3361
4224
  local track = reaper.GetTrack(0, params.trackIndex)
@@ -3370,12 +4233,18 @@ function handlers.set_envelope_properties(params)
3370
4233
 
3371
4234
  if reaper.BR_EnvAlloc then
3372
4235
  -- SWS extension available: use BR_Env* functions
4236
+ -- BR_EnvSetProperties needs active, visible, armed, inLane, laneHeight, defaultShape and
4237
+ -- faderScaling; pass the current values back for everything not being changed.
3373
4238
  local br_env = reaper.BR_EnvAlloc(env, false)
3374
- local cur_active, cur_visible, cur_armed = reaper.BR_EnvGetProperties(br_env)
3375
- local new_active = params.active ~= nil and params.active or cur_active
3376
- local new_visible = params.visible ~= nil and params.visible or cur_visible
3377
- local new_armed = params.armed ~= nil and params.armed or cur_armed
3378
- reaper.BR_EnvSetProperties(br_env, new_active, new_visible, new_armed, false)
4239
+ local cur = { reaper.BR_EnvGetProperties(br_env) }
4240
+ local function pick(v, current) if v == nil then return current end return v end
4241
+ local args = {
4242
+ pick(params.active, cur[1]), pick(params.visible, cur[2]), pick(params.armed, cur[3]),
4243
+ cur[4], cur[5], cur[6], cur[11],
4244
+ }
4245
+ local n = 7
4246
+ if cur[12] ~= nil then n = 8; args[8] = cur[12] end -- automationItemsOptions (newer SWS)
4247
+ reaper.BR_EnvSetProperties(br_env, table.unpack(args, 1, n))
3379
4248
  reaper.BR_EnvFree(br_env, true) -- commit=true
3380
4249
  else
3381
4250
  -- Fallback: modify envelope state chunk directly
@@ -3419,91 +4288,105 @@ function handlers.set_envelope_properties(params)
3419
4288
  end
3420
4289
 
3421
4290
  function handlers.clear_envelope(params)
3422
- local track = reaper.GetTrack(0, params.trackIndex)
3423
- if not track then return nil, "Track " .. params.trackIndex .. " not found" end
3424
- local env_count = reaper.CountTrackEnvelopes(track)
3425
- if params.envelopeIndex >= env_count then
3426
- return nil, "Envelope index " .. params.envelopeIndex .. " out of range (track has " .. env_count .. " envelopes)"
3427
- end
3428
- local env = reaper.GetTrackEnvelope(track, params.envelopeIndex)
3429
- local prev_count = reaper.CountEnvelopePoints(env)
3430
- -- Delete all points by using a range from -infinity to +infinity
3431
- reaper.Undo_BeginBlock()
3432
- reaper.DeleteEnvelopePointRange(env, -math.huge, math.huge)
3433
- reaper.Envelope_SortPoints(env)
3434
- reaper.Undo_EndBlock("MCP: Clear envelope", -1)
3435
- return {
3436
- trackIndex = params.trackIndex,
3437
- envelopeIndex = params.envelopeIndex,
3438
- deletedPoints = prev_count,
3439
- totalPoints = reaper.CountEnvelopePoints(env),
3440
- }
4291
+ return env_ext.run("remove_range", { trackIndex = params.trackIndex, envelopeIndex = params.envelopeIndex,
4292
+ automationItemIndex = params.automationItemIndex }, "MCP: Clear envelope")
3441
4293
  end
3442
4294
 
3443
4295
  function handlers.remove_envelope_points(params)
3444
- local track = reaper.GetTrack(0, params.trackIndex)
3445
- if not track then return nil, "Track " .. params.trackIndex .. " not found" end
3446
- local env_count = reaper.CountTrackEnvelopes(track)
3447
- if params.envelopeIndex >= env_count then
3448
- return nil, "Envelope index " .. params.envelopeIndex .. " out of range (track has " .. env_count .. " envelopes)"
3449
- end
3450
- local env = reaper.GetTrackEnvelope(track, params.envelopeIndex)
3451
- local prev_count = reaper.CountEnvelopePoints(env)
3452
- reaper.Undo_BeginBlock()
3453
- reaper.DeleteEnvelopePointRange(env, params.timeStart, params.timeEnd)
3454
- reaper.Envelope_SortPoints(env)
3455
- reaper.Undo_EndBlock("MCP: Remove envelope points", -1)
3456
- local new_count = reaper.CountEnvelopePoints(env)
3457
- return {
3458
- trackIndex = params.trackIndex,
3459
- envelopeIndex = params.envelopeIndex,
3460
- timeStart = params.timeStart,
3461
- timeEnd = params.timeEnd,
3462
- deletedPoints = prev_count - new_count,
3463
- totalPoints = new_count,
3464
- }
4296
+ return env_ext.run("remove_range", params, "MCP: Remove envelope points")
3465
4297
  end
3466
4298
 
3467
- function handlers.insert_envelope_points(params)
3468
- local track = reaper.GetTrack(0, params.trackIndex)
3469
- if not track then return nil, "Track " .. params.trackIndex .. " not found" end
3470
- local env_count = reaper.CountTrackEnvelopes(track)
3471
- if params.envelopeIndex >= env_count then
3472
- return nil, "Envelope index " .. params.envelopeIndex .. " out of range (track has " .. env_count .. " envelopes)"
3473
- end
3474
- local env = reaper.GetTrackEnvelope(track, params.envelopeIndex)
4299
+ function handlers.list_automation_items(params) return env_ext.run("list_items", params) end
3475
4300
 
3476
- -- Parse the points JSON string
3477
- local points_str = params.points
3478
- local points = nil
3479
- if type(points_str) == "string" then
3480
- points = json_decode(points_str)
3481
- elseif type(points_str) == "table" then
3482
- points = points_str
4301
+ function handlers.insert_automation_item(params)
4302
+ local points
4303
+ if params.points ~= nil then
4304
+ points = env_ext.list(params.points)
4305
+ if not points then return nil, "Failed to parse points JSON" end
3483
4306
  end
3484
- if not points then return nil, "Failed to parse points JSON" end
4307
+ return env_ext.run("insert_item", params, points)
4308
+ end
3485
4309
 
3486
- local inserted = 0
3487
- reaper.Undo_BeginBlock()
3488
- for _, pt in ipairs(points) do
3489
- if pt.time and pt.value then
3490
- local shape = pt.shape or 0
3491
- local tension = pt.tension or 0
3492
- reaper.InsertEnvelopePoint(env, pt.time, pt.value, shape, tension, false, true)
3493
- inserted = inserted + 1
4310
+ function handlers.set_automation_item(params) return env_ext.run("set_item", params) end
4311
+
4312
+ function handlers.delete_automation_item(params) return env_ext.run("delete_item", params) end
4313
+
4314
+ -- =============================================================================
4315
+ -- MIDI composition: quantize, transform, file import, pitch profile
4316
+ -- =============================================================================
4317
+
4318
+ -- Logic in mcp_midi_lib.lua; one table for the 200-locals limit. Nested params (note
4319
+ -- indexes, the velocity/humanize/length objects, an import's tracks) travel as JSON strings.
4320
+ ext.midi = {}
4321
+ ext.midi.ok, ext.midi.lib = pcall(dofile, script_dir .. "mcp_midi_lib.lua")
4322
+
4323
+ function ext.midi.run(name, params)
4324
+ if not ext.midi.ok then
4325
+ return nil, "mcp_midi_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(ext.midi.lib) .. ")"
4326
+ end
4327
+ local p = {}
4328
+ for k, v in pairs(params) do p[k] = v end
4329
+ for _, k in ipairs({ "notes", "velocity", "humanize", "length", "tracks" }) do
4330
+ if type(p[k]) == "string" then
4331
+ if not plan_lib_ok then return nil, "mcp_plan_lib.lua is not installed — run `reaper-mcp setup`" end
4332
+ local val, err = plan_lib.json_decode(p[k])
4333
+ if type(val) ~= "table" then return nil, k .. " is not valid JSON" .. (err and (" (" .. tostring(err) .. ")") or "") .. " — nothing changed" end
4334
+ p[k] = val
3494
4335
  end
3495
4336
  end
4337
+ local ok, res, err = pcall(ext.midi.lib[name], reaper, p)
4338
+ if not ok then return nil, "Handler error: " .. tostring(res) end
4339
+ return res, err
4340
+ end
3496
4341
 
3497
- reaper.Envelope_SortPoints(env)
3498
- reaper.Undo_EndBlock("MCP: Insert " .. inserted .. " envelope points", -1)
3499
- return {
3500
- trackIndex = params.trackIndex,
3501
- envelopeIndex = params.envelopeIndex,
3502
- insertedPoints = inserted,
3503
- totalPoints = reaper.CountEnvelopePoints(env),
3504
- }
4342
+ function handlers.quantize_midi(params) return ext.midi.run("quantize", params) end
4343
+ function handlers.transform_midi(params) return ext.midi.run("transform", params) end
4344
+ function handlers.import_midi_file(params) return ext.midi.run("import", params) end
4345
+ -- Internal (detect_key on a MIDI track): not logged, not counted, but refused while paused,
4346
+ -- since it answers an agent's tool call (a read, so read-only mode allows it)
4347
+ handlers["_midi_pitch_profile"] = function(params) return ext.midi.run("pitch_profile", params) end
4348
+ -- Internal, likewise: detect_chords' bar/beat grid (+ MIDI profiles), and an item's range (itemIndex)
4349
+ handlers["_chord_grid"] = function(params) return ext.midi.run("chord_grid", params) end
4350
+ handlers["_item_range"] = function(params) return ext.midi.run("item_range", params) end
4351
+ ext.paused_too = { _midi_pitch_profile = true, _chord_grid = true, _item_range = true }
4352
+
4353
+ -- =============================================================================
4354
+ -- Arrangement: copy items, insert/remove time, copy a section, process items
4355
+ -- =============================================================================
4356
+
4357
+ -- Logic in mcp_arrange_lib.lua (one table, like env_ext: the main chunk is at the locals limit)
4358
+ ext.arrange = {}
4359
+ ext.arrange.ok, ext.arrange.lib = pcall(dofile, script_dir .. "mcp_arrange_lib.lua")
4360
+ plan_ctx.arrange = ext.arrange.ok and ext.arrange.lib or nil
4361
+
4362
+ -- `items` / `tracks` arrive as JSON strings (the bridge decodes only the top level)
4363
+ function ext.arrange.run(name, params)
4364
+ if not ext.arrange.ok then
4365
+ return nil, "mcp_arrange_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(ext.arrange.lib) .. ")"
4366
+ end
4367
+ for _, key in ipairs({ "items", "tracks" }) do
4368
+ if type(params[key]) == "string" then
4369
+ if not plan_lib_ok then return nil, "mcp_plan_lib.lua is not installed next to mcp_bridge.lua" end
4370
+ local ok, v = pcall(plan_lib.json_decode, params[key])
4371
+ if not ok or type(v) ~= "table" then return nil, key .. " is not a JSON array — nothing changed" end
4372
+ params[key] = v
4373
+ end
4374
+ end
4375
+ local ok, res, err = pcall(ext.arrange.lib[name], reaper, params)
4376
+ if not ok then return nil, "Handler error: " .. tostring(res) end
4377
+ return res, err
3505
4378
  end
3506
4379
 
4380
+ function handlers.copy_media_items(params) return ext.arrange.run("copy_items", params) end
4381
+
4382
+ function handlers.insert_time(params) return ext.arrange.run("insert_time", params) end
4383
+
4384
+ function handlers.remove_time(params) return ext.arrange.run("remove_time", params) end
4385
+
4386
+ function handlers.copy_section(params) return ext.arrange.run("copy_section", params) end
4387
+
4388
+ function handlers.process_items(params) return ext.arrange.run("process_items", params) end
4389
+
3507
4390
  -- =============================================================================
3508
4391
  -- Live MIDI output handlers
3509
4392
  -- =============================================================================
@@ -3617,190 +4500,64 @@ function handlers.send_midi_note(params)
3617
4500
  end
3618
4501
 
3619
4502
  -- =============================================================================
3620
- -- render_track_to_wav handler
4503
+ -- render_track_to_wav / render_tracks_to_wav handlers
3621
4504
  --
3622
- -- Renders a target track's output (post-FX, post-fader) to a temp WAV file.
3623
- -- Saves and restores all render settings and solo states around the render.
3624
- -- Blocks the defer loop during render (~0.5-1s) — this is by design.
4505
+ -- Render tracks' output (post-FX, post-fader) to temp WAVs for the analysis tools. The logic is in
4506
+ -- mcp_render_lib.lua: it caches renders until the project changes, renders several tracks in one
4507
+ -- pass and hands focus back after a render (REAPER's progress window takes it). One table, not
4508
+ -- separate locals: the bridge's main chunk is at Lua's 200-locals limit.
3625
4509
  --
3626
- -- Params:
3627
- -- trackIndex (number) — zero-based track index
3628
- -- startTime (number) — render start, seconds from project start
3629
- -- endTime (number) — render end, seconds from project start
3630
- -- commandId (string) — used to generate a unique temp filename
3631
- --
3632
- -- Returns:
3633
- -- { trackIndex, trackName, wavPath, durationSeconds, sampleRate, channelCount }
4510
+ -- render_track_to_wav params: trackIndex (-1 = master), startTime, endTime (seconds), commandId,
4511
+ -- fresh (skip the cache), restoreFocus (false: leave focus where the render put it)
4512
+ -- -> { trackIndex, trackName, wavPath, durationSeconds, sampleRate, channelCount,
4513
+ -- playOffsetMs, phaseInverted, cached, cacheOwned }
4514
+ -- render_tracks_to_wav: the same with trackIndices (JSON array) -> { tracks, rendered, cached }
3634
4515
  -- =============================================================================
3635
4516
 
3636
- function handlers.render_track_to_wav(params)
3637
- local track_index = params.trackIndex
3638
- local start_time = params.startTime
3639
- local end_time = params.endTime
3640
- local command_id = params.commandId or tostring(os.time())
3641
-
3642
- -- Validate inputs
3643
- if track_index == nil then
3644
- return nil, "Missing required param: trackIndex"
3645
- end
3646
- if start_time == nil or end_time == nil then
3647
- return nil, "Missing required params: startTime, endTime"
3648
- end
3649
- if end_time <= start_time then
3650
- return nil, "endTime must be greater than startTime"
3651
- end
4517
+ ext.render = {}
4518
+ ext.render.ok, ext.render.lib = pcall(dofile, script_dir .. "mcp_render_lib.lua")
3652
4519
 
3653
- -- Get track
3654
- local track = reaper.GetTrack(0, track_index)
3655
- if not track then
3656
- return nil, "Track " .. tostring(track_index) .. " not found"
4520
+ function ext.render.run(name, params)
4521
+ if not ext.render.ok then
4522
+ return nil, "mcp_render_lib.lua is not installed next to mcp_bridge.lua — run `reaper-mcp setup` and restart the bridge (" .. tostring(ext.render.lib) .. ")"
3657
4523
  end
3658
- local track_name = ({reaper.GetSetMediaTrackInfo_String(track, "P_NAME", "", false)})[2] or ""
3659
- if track_name == "" then track_name = "Track " .. (track_index + 1) end
3660
-
3661
- -- Render target is always 44100 Hz (WAV for sidecar inference).
3662
- -- The actual project sample rate is irrelevant here; Audiobox resamples internally.
3663
- local sample_rate = 44100
3664
-
3665
- -- Build unique temp file path
3666
- -- Use GetTempPath() if available (REAPER 6.29+), fall back to os.tmpname pattern
3667
- local temp_dir
3668
- if reaper.GetTempPath then
3669
- temp_dir = reaper.GetTempPath()
3670
- else
3671
- -- Fallback: derive temp dir from os.tmpname()
3672
- local t = os.tmpname()
3673
- temp_dir = t:match("^(.+)[/\\][^/\\]+$") or "/tmp"
3674
- os.remove(t)
3675
- end
3676
-
3677
- -- Ensure temp dir ends with separator
3678
- if temp_dir:sub(-1) ~= "/" and temp_dir:sub(-1) ~= "\\" then
3679
- temp_dir = temp_dir .. "/"
3680
- end
3681
-
3682
- local wav_filename = "mcp_aes_" .. command_id:gsub("-", ""):sub(1, 16) .. ".wav"
3683
- local wav_path = temp_dir .. wav_filename
3684
-
3685
- -- -------------------------------------------------------------------------
3686
- -- Save current render settings
3687
- -- -------------------------------------------------------------------------
3688
- local saved_file = ({reaper.GetSetProjectInfo_String(0, "RENDER_FILE", "", false)})[2] or ""
3689
- local saved_pattern = ({reaper.GetSetProjectInfo_String(0, "RENDER_PATTERN", "", false)})[2] or ""
3690
- local saved_format = ({reaper.GetSetProjectInfo_String(0, "RENDER_FORMAT", "", false)})[2] or ""
3691
- local saved_bounds = reaper.GetSetProjectInfo(0, "RENDER_BOUNDSFLAG", 0, false)
3692
- local saved_settings = reaper.GetSetProjectInfo(0, "RENDER_SETTINGS", 0, false)
3693
- local saved_start = reaper.GetSetProjectInfo(0, "RENDER_STARTPOS", 0, false)
3694
- local saved_end = reaper.GetSetProjectInfo(0, "RENDER_ENDPOS", 0, false)
3695
- local saved_srate = reaper.GetSetProjectInfo(0, "RENDER_SRATE", 0, false)
3696
- local saved_channels = reaper.GetSetProjectInfo(0, "RENDER_CHANNELS", 0, false)
3697
-
3698
- -- Save solo states for all tracks
3699
- local track_count = reaper.CountTracks(0)
3700
- local saved_solo_states = {}
3701
- for i = 0, track_count - 1 do
3702
- local t = reaper.GetTrack(0, i)
3703
- saved_solo_states[i] = reaper.GetMediaTrackInfo_Value(t, "I_SOLO")
3704
- end
3705
-
3706
- -- -------------------------------------------------------------------------
3707
- -- Apply render settings
3708
- -- -------------------------------------------------------------------------
3709
- local ok, restore_err = pcall(function()
3710
- -- Set output: render to temp dir with unique filename (no extension in pattern)
3711
- reaper.GetSetProjectInfo_String(0, "RENDER_FILE", temp_dir, true)
3712
- reaper.GetSetProjectInfo_String(0, "RENDER_PATTERN", wav_filename:match("^(.+)%.wav$") or wav_filename, true)
3713
-
3714
- -- Set render bounds: 0 = custom time range
3715
- reaper.GetSetProjectInfo(0, "RENDER_BOUNDSFLAG", 0, true)
3716
- reaper.GetSetProjectInfo(0, "RENDER_STARTPOS", start_time, true)
3717
- reaper.GetSetProjectInfo(0, "RENDER_ENDPOS", end_time, true)
3718
-
3719
- -- Set render mode: 2 = stems (selected tracks via master mix / solo)
3720
- reaper.GetSetProjectInfo(0, "RENDER_SETTINGS", 2, true)
3721
-
3722
- -- Force WAV at 44100 Hz stereo so the sidecar always gets a known format,
3723
- -- independent of whatever the user last rendered. Restored in finally.
3724
- -- "evaw" is the little-endian "wave" fourcc REAPER uses for WAV format.
3725
- reaper.GetSetProjectInfo_String(0, "RENDER_FORMAT", "evaw", true)
3726
- reaper.GetSetProjectInfo(0, "RENDER_SRATE", sample_rate, true)
3727
- reaper.GetSetProjectInfo(0, "RENDER_CHANNELS", 2, true)
3728
-
3729
- -- Unsolo all tracks, then solo only the target track
3730
- for i = 0, track_count - 1 do
3731
- local t = reaper.GetTrack(0, i)
3732
- reaper.SetMediaTrackInfo_Value(t, "I_SOLO", 0)
3733
- end
3734
- reaper.SetMediaTrackInfo_Value(track, "I_SOLO", 1)
3735
-
3736
- -- Trigger render: action 42230 = "Render project, using the most recent render settings, auto-close render dialog"
3737
- -- This call is synchronous — it blocks the defer loop until rendering completes.
3738
- reaper.Main_OnCommand(42230, 0)
3739
- end)
3740
-
3741
- -- -------------------------------------------------------------------------
3742
- -- Restore all settings and solo states (always, even on error)
3743
- -- -------------------------------------------------------------------------
3744
- local restore_ok, restore_err2 = pcall(function()
3745
- reaper.GetSetProjectInfo_String(0, "RENDER_FILE", saved_file, true)
3746
- reaper.GetSetProjectInfo_String(0, "RENDER_PATTERN", saved_pattern, true)
3747
- reaper.GetSetProjectInfo_String(0, "RENDER_FORMAT", saved_format, true)
3748
- reaper.GetSetProjectInfo(0, "RENDER_BOUNDSFLAG", saved_bounds, true)
3749
- reaper.GetSetProjectInfo(0, "RENDER_SETTINGS", saved_settings, true)
3750
- reaper.GetSetProjectInfo(0, "RENDER_STARTPOS", saved_start, true)
3751
- reaper.GetSetProjectInfo(0, "RENDER_ENDPOS", saved_end, true)
3752
- reaper.GetSetProjectInfo(0, "RENDER_SRATE", saved_srate, true)
3753
- reaper.GetSetProjectInfo(0, "RENDER_CHANNELS", saved_channels, true)
3754
- for i = 0, track_count - 1 do
3755
- local t = reaper.GetTrack(0, i)
3756
- reaper.SetMediaTrackInfo_Value(t, "I_SOLO", saved_solo_states[i] or 0)
3757
- end
3758
- end)
4524
+ local ok, res, err = pcall(ext.render.lib[name], reaper, params)
4525
+ if not ok then return nil, "Handler error: " .. tostring(res) end
4526
+ return res, err
4527
+ end
3759
4528
 
3760
- if not restore_ok then
3761
- -- Log but don't fail — the render may have succeeded
3762
- reaper.ShowConsoleMsg("[reaper-mcp] Warning: failed to restore render settings: " .. tostring(restore_err2) .. "\n")
3763
- end
4529
+ function handlers.render_track_to_wav(params)
4530
+ return ext.render.run("render_track", params)
4531
+ end
3764
4532
 
3765
- if not ok then
3766
- return nil, "Render failed: " .. tostring(restore_err)
3767
- end
3768
-
3769
- -- Verify the file was actually created.
3770
- -- RENDER_PATTERN was set without the .wav extension so REAPER appends it;
3771
- -- wav_path already has the full expected name including the extension.
3772
- local f = io.open(wav_path, "rb")
3773
- if not f then
3774
- return nil, "Render produced no output file at: " .. wav_path
3775
- end
3776
- -- Verify the rendered file is actually a WAV. RENDER_FORMAT was set to "evaw"
3777
- -- (REAPER's WAV fourcc) above; if REAPER silently ignored that value the file
3778
- -- could be any codec the user last rendered (MP3/FLAC/etc.). Check the RIFF
3779
- -- magic bytes so we fail loudly here rather than crashing in the Python sidecar
3780
- -- with a confusing "Cannot read audio file" error.
3781
- local magic = f:read(4)
3782
- f:close()
3783
- if magic ~= "RIFF" then
3784
- os.remove(wav_path)
3785
- return nil, "Render produced a non-WAV file (RENDER_FORMAT may not have been applied). " ..
3786
- "First 4 bytes: " .. (magic and string.format("%q", magic) or "<empty>")
4533
+ function handlers.render_tracks_to_wav(params)
4534
+ local p = {}
4535
+ for k, v in pairs(params) do p[k] = v end
4536
+ -- Without CF_Json_Parse the command parser hands arrays over as a JSON string
4537
+ if type(params.trackIndices) == "string" then
4538
+ local list = {}
4539
+ for n in params.trackIndices:gmatch("%-?%d+") do list[#list + 1] = tonumber(n) end
4540
+ p.trackIndices = list
3787
4541
  end
3788
-
3789
- local duration = end_time - start_time
3790
- return {
3791
- trackIndex = track_index,
3792
- trackName = track_name,
3793
- wavPath = wav_path,
3794
- durationSeconds = duration,
3795
- sampleRate = sample_rate,
3796
- channelCount = 2,
3797
- }
4542
+ return ext.render.run("render_tracks", p)
3798
4543
  end
3799
4544
 
3800
4545
  -- =============================================================================
3801
4546
  -- Bridge diagnostics handler
3802
4547
  -- =============================================================================
3803
4548
 
4549
+ -- Session ping from each MCP server (see apps/reaper-mcp-server/src/session.ts); the session itself
4550
+ -- is recorded from the command's top-level fields in record_command
4551
+ handlers["_session"] = function(params)
4552
+ return { ok = true }
4553
+ end
4554
+
4555
+ -- A sidecar tool's result for the status page; recorded by record_command (record_tool_result)
4556
+ handlers["_log_result"] = function(params)
4557
+ if type(params.command) ~= "string" then return nil, "command required" end
4558
+ return { logged = true }
4559
+ end
4560
+
3804
4561
  handlers["_bridge_diagnostics"] = function(params)
3805
4562
  -- Compute defer interval stats from circular buffer
3806
4563
  local intervals = {}
@@ -3843,31 +4600,740 @@ handlers["_bridge_diagnostics"] = function(params)
3843
4600
  end
3844
4601
 
3845
4602
  -- =============================================================================
3846
- -- Command dispatcher
4603
+ -- Status feed (read by the web UI: reaper_www_root/mcp_bridge.html)
3847
4604
  -- =============================================================================
4605
+ -- Published as JSON to non-persistent ExtState (in-memory, no disk IO). The UI
4606
+ -- polls it through REAPER's web interface, so all rendering happens in the
4607
+ -- browser, outside REAPER's main thread. Publishing is throttled and only
4608
+ -- happens when something changed (plus a 1s liveness tick from the heartbeat).
4609
+
4610
+ local STATUS_SECTION = "MCPBridge"
4611
+ local STATUS_RECENT_MAX = 50
4612
+ local STATUS_MIN_PUBLISH_INTERVAL = 0.2
4613
+
4614
+ -- os.time() has 1s resolution; anchor time_precise() to it for ms timestamps
4615
+ local epoch_offset = os.time() - reaper.time_precise()
4616
+ local function now_ms()
4617
+ return math.floor((reaper.time_precise() + epoch_offset) * 1000)
4618
+ end
3848
4619
 
3849
- local function process_command(filename)
3850
- local pickup_time = reaper.time_precise()
3851
- local path = bridge_dir .. filename
3852
- local content = read_file(path)
3853
- if not content then return end
4620
+ local status = {
4621
+ startedAt = now_ms(),
4622
+ commands = 0,
4623
+ errors = 0,
4624
+ recent = {}, -- newest first
4625
+ }
4626
+ local status_dirty = true
4627
+ local last_status_publish = 0
4628
+
4629
+ -- Agent sessions (one per MCP server process): commands carry sessionId/sessionDir/sessionClient,
4630
+ -- and each server pings `_session` every 20s so idle agents still show as connected.
4631
+ local SESSIONS_MAX = 20
4632
+ local SESSION_FORGET_S = 3600
4633
+ local sessions = {}
4634
+
4635
+ local function touch_session(cmd, ok, failed)
4636
+ local id = cmd.sessionId
4637
+ if type(id) ~= "string" or id == "" then return nil end
4638
+ local s = sessions[id]
4639
+ local t = now_ms()
4640
+ if not s then
4641
+ s = { id = id, first = t, commands = 0, errors = 0 }
4642
+ sessions[id] = s
4643
+ end
4644
+ s.last = t
4645
+ if type(cmd.sessionDir) == "string" and cmd.sessionDir ~= "" then s.dir = cmd.sessionDir end
4646
+ if type(cmd.sessionClient) == "string" and cmd.sessionClient ~= "" then s.client = cmd.sessionClient end
4647
+ s.ended = nil
4648
+ if ok ~= nil then
4649
+ s.commands = s.commands + 1
4650
+ if failed then s.errors = s.errors + 1 end
4651
+ end
4652
+ status_dirty = true
4653
+ return s
4654
+ end
3854
4655
 
3855
- local cmd = json_decode(content)
3856
- if not cmd or not cmd.id or not cmd.type then
3857
- -- Invalid command, remove file
3858
- os.remove(path)
4656
+ local function session_list()
4657
+ local list, cutoff = {}, now_ms() - SESSION_FORGET_S * 1000
4658
+ for id, s in pairs(sessions) do
4659
+ if s.last < cutoff then sessions[id] = nil else list[#list + 1] = s end
4660
+ end
4661
+ table.sort(list, function(a, b) return a.last > b.last end)
4662
+ for i = SESSIONS_MAX + 1, #list do
4663
+ sessions[list[i].id] = nil
4664
+ list[i] = nil
4665
+ end
4666
+ return list
4667
+ end
4668
+
4669
+ -- Settings written by the status page through REAPER's web interface (/_/SET/EXTSTATE...).
4670
+ -- cfg_paused is in-memory only (resets with REAPER); the others persist in reaper-extstate.ini.
4671
+ local function cfg(key)
4672
+ return reaper.GetExtState(STATUS_SECTION, key) == "1"
4673
+ end
4674
+
4675
+ -- Read-only mode lets agents inspect and audition but never change the project
4676
+ local READONLY_PREFIXES = { "get_", "list_", "read_", "analyze_", "search_", "measure_" }
4677
+ local READONLY_ALLOW = {
4678
+ snapshot_list = true, render_track_to_wav = true, render_tracks_to_wav = true,
4679
+ play = true, stop = true, set_cursor_position = true, set_time_selection = true,
4680
+ }
4681
+ local function readonly_allows(cmd_type)
4682
+ if READONLY_ALLOW[cmd_type] then return true end
4683
+ for _, p in ipairs(READONLY_PREFIXES) do
4684
+ if cmd_type:sub(1, #p) == p then return true end
4685
+ end
4686
+ return false
4687
+ end
4688
+
4689
+ -- Setup health and staleness, re-checked every 10s (cheap: one file read + a few stats)
4690
+ -- Read at load: inside deferred callbacks get_action_context no longer describes this script's launch
4691
+ local _, script_path, _, script_cmd_id = reaper.get_action_context()
4692
+ local script_source = script_path and read_file(script_path)
4693
+ local HEALTH_INTERVAL = 10
4694
+ local last_health_check = -HEALTH_INTERVAL
4695
+ local health = { sws = false, jsfx = true, outdated = false }
4696
+ local JSFX_FILES = { "mcp_analyzer.jsfx", "mcp_lufs_meter.jsfx", "mcp_correlation_meter.jsfx", "mcp_crest_factor.jsfx", "mcp_midi_emitter.jsfx" }
4697
+
4698
+ local function check_health(now)
4699
+ if now - last_health_check < HEALTH_INTERVAL then return end
4700
+ last_health_check = now
4701
+ local fx_dir = reaper.GetResourcePath() .. "/Effects/reaper-mcp/"
4702
+ local missing = {}
4703
+ for _, f in ipairs(JSFX_FILES) do
4704
+ if not file_exists(fx_dir .. f) then missing[#missing + 1] = f end
4705
+ end
4706
+ local installed = script_path and read_file(script_path)
4707
+ local next_health = {
4708
+ sws = type(reaper.CF_GetSWSVersion) == "function",
4709
+ jsfx = #missing == 0,
4710
+ missingJsfx = #missing > 0 and missing or nil,
4711
+ -- The installed script changed under us (setup ran): this running copy is stale until restarted
4712
+ outdated = script_source ~= nil and installed ~= nil and installed ~= script_source,
4713
+ }
4714
+ if next_health.sws ~= health.sws or next_health.jsfx ~= health.jsfx or next_health.outdated ~= health.outdated then
4715
+ status_dirty = true
4716
+ end
4717
+ health = next_health
4718
+ end
4719
+
4720
+ -- Self-update and restart, requested from the status page with one click.
4721
+ -- The page writes ExtState MCPBridge/request = "<update|restart>-<token>" through REAPER's web interface.
4722
+ -- The token is random per run and only readable from the status feed: REAPER's web server sends no CORS
4723
+ -- headers, so another website can fire a SET at localhost but can't read the token to make it count.
4724
+ -- Updating runs <clone>/scripts/update.sh (git pull --ff-only + install.sh) detached, so REAPER never blocks;
4725
+ -- `reaper-mcp setup` writes mcp_update.conf (clone path + node binary) next to this script.
4726
+ math.randomseed(math.floor(reaper.time_precise() * 1e6) % 2147483647)
4727
+ local control_token = string.format("%08x%08x", math.random(0, 0x7fffffff), math.random(0, 0x7fffffff))
4728
+ local is_windows = (reaper.GetOS and reaper.GetOS() or ""):match("^Win") ~= nil
4729
+ local UPDATE_LOG = bridge_dir .. "update.log"
4730
+ local UPDATE_EXIT = bridge_dir .. "update.exit"
4731
+ local SOURCE_CHANGES = bridge_dir .. "source_changes"
4732
+ local update = nil -- { state = "running" | "failed" | "done", startedAt, log = { lines } }
4733
+ local last_update_poll = 0
4734
+
4735
+ -- REAPER 7.03+: re-running this script stops it and starts the (possibly newer) installed copy
4736
+ local can_restart = type(reaper.set_action_options) == "function" and type(script_cmd_id) == "number" and script_cmd_id > 0
4737
+ -- Running the script again stops it (1: terminate when re-launched); only a restart the bridge asks for itself
4738
+ -- adds 2 (then start again), so a user re-running mcp_bridge.lua turns it off instead of getting a new window
4739
+ if can_restart then reaper.set_action_options(1) end
4740
+
4741
+ local function update_config()
4742
+ local text = read_file(script_dir .. "mcp_update.conf")
4743
+ if not text then return nil end
4744
+ local conf = {}
4745
+ for key, value in text:gmatch("([%w_]+)=([^\r\n]*)") do conf[key] = value end
4746
+ if not conf.repo or conf.repo == "" or not file_exists(conf.repo .. "/scripts/update.sh") then return nil end
4747
+ return conf
4748
+ end
4749
+
4750
+ local function restart_bridge()
4751
+ log_event("bridge_restart", {})
4752
+ reaper.set_action_options(3)
4753
+ reaper.Main_OnCommand(script_cmd_id, 0)
4754
+ end
4755
+
4756
+ local function shell_quote(s)
4757
+ return "'" .. s:gsub("'", "'\\''") .. "'"
4758
+ end
4759
+
4760
+ -- kind "update": pull the latest version and reinstall; "rebuild": developers, build the working tree as it is
4761
+ local function start_update(kind)
4762
+ if is_windows or (update and update.state == "running") then return end
4763
+ local conf = update_config()
4764
+ if not conf then return end
4765
+ os.remove(UPDATE_LOG)
4766
+ os.remove(UPDATE_EXIT)
4767
+ -- REAPER started from the Dock has a bare PATH: put the node that ran setup (and its pnpm/corepack) first
4768
+ local node_dir = conf.node and conf.node:match("^(.*)/[^/]+$")
4769
+ local path = (node_dir and (shell_quote(node_dir) .. ":") or "") .. "/opt/homebrew/bin:/usr/local/bin:$PATH"
4770
+ os.execute("( cd " .. shell_quote(conf.repo) .. " && PATH=" .. path .. " ./scripts/update.sh" .. (kind == "rebuild" and " --rebuild" or "") .. " < /dev/null > " .. shell_quote(UPDATE_LOG)
4771
+ .. " 2>&1; echo $? > " .. shell_quote(UPDATE_EXIT) .. " ) >/dev/null 2>&1 &")
4772
+ update = { kind = kind, state = "running", startedAt = now_ms(), log = {} }
4773
+ log_event("bridge_update_start", { kind = kind })
4774
+ status_dirty = true
4775
+ end
4776
+
4777
+ -- Last lines of the update log, without colors or carriage-return progress redraws
4778
+ local function update_log_tail(max)
4779
+ local text = read_file(UPDATE_LOG) or ""
4780
+ local lines = {}
4781
+ for line in text:gmatch("[^\n]+") do
4782
+ line = line:gsub("\27%[[%d;]*%a", ""):match("([^\r]*)\r*$") or ""
4783
+ if line:match("%S") then lines[#lines + 1] = line end
4784
+ end
4785
+ local tail = {}
4786
+ for i = math.max(1, #lines - max + 1), #lines do tail[#tail + 1] = lines[i] end
4787
+ return tail
4788
+ end
4789
+
4790
+ local function check_control(now)
4791
+ local request = reaper.GetExtState(STATUS_SECTION, "request")
4792
+ if request and request ~= "" then
4793
+ reaper.DeleteExtState(STATUS_SECTION, "request", false)
4794
+ local action, token = request:match("^(%a+)%-(%x+)$")
4795
+ if token == control_token then
4796
+ if action == "update" or action == "rebuild" then start_update(action)
4797
+ elseif action == "restart" and can_restart then restart_bridge() return end
4798
+ end
4799
+ end
4800
+ if not update or update.state ~= "running" or now - last_update_poll < 1 then return end
4801
+ last_update_poll = now
4802
+ local log = update_log_tail(12)
4803
+ if #log ~= #update.log or log[#log] ~= update.log[#update.log] then
4804
+ update.log = log
4805
+ status_dirty = true
4806
+ end
4807
+ local code = read_file(UPDATE_EXIT)
4808
+ if not code then return end
4809
+ code = tonumber(code:match("%d+"))
4810
+ update.state = code == 0 and "done" or "failed"
4811
+ if update.state == "done" then os.remove(SOURCE_CHANGES) end -- re-checked against the new install
4812
+ log_event("bridge_update_end", { exitCode = code })
4813
+ status_dirty = true
4814
+ -- The installer replaced this script: load the new copy (the page reloads when startedAt changes)
4815
+ if update.state == "done" and can_restart then
4816
+ local installed = script_path and read_file(script_path)
4817
+ if installed and installed ~= script_source then restart_bridge() end
4818
+ end
4819
+ end
4820
+
4821
+ -- What the status page may offer: published in the status feed together with the token
4822
+ local can_update, conf_dev, last_can_update_check = false, false, -10
4823
+ local function refresh_update_config()
4824
+ local now = reaper.time_precise()
4825
+ if now - last_can_update_check < 10 then return end
4826
+ last_can_update_check = now
4827
+ local conf = not is_windows and update_config() or nil
4828
+ can_update, conf_dev = conf ~= nil, conf ~= nil and conf.dev == "1"
4829
+ end
4830
+
4831
+ -- Developer mode: the page's toggle (cfg_dev "1"/"0") once chosen, else what setup detected (dev=1 in mcp_update.conf)
4832
+ local function dev_mode()
4833
+ refresh_update_config()
4834
+ local v = reaper.GetExtState(STATUS_SECTION, "cfg_dev")
4835
+ if v == nil or v == "" then return conf_dev end
4836
+ return v == "1"
4837
+ end
4838
+
4839
+ -- Developer mode: whether the clone has source files newer than the last install (setup rewrites
4840
+ -- mcp_update.conf, so its mtime marks it). find runs detached every few seconds and leaves the first
4841
+ -- match in SOURCE_CHANGES; git and the file walk stay off REAPER's main thread.
4842
+ local SOURCE_CHECK_INTERVAL = 5
4843
+ local source_changed, last_source_check = false, -SOURCE_CHECK_INTERVAL
4844
+ local function check_source_changes(now)
4845
+ if is_windows or now - last_source_check < SOURCE_CHECK_INTERVAL or not dev_mode() or not can_update then return end
4846
+ if update and update.state == "running" then return end
4847
+ last_source_check = now
4848
+ local changed = (read_file(SOURCE_CHANGES) or ""):match("%S") ~= nil
4849
+ if changed ~= source_changed then
4850
+ source_changed = changed
4851
+ status_dirty = true
4852
+ end
4853
+ local conf = update_config()
4854
+ if not conf then return end
4855
+ local tmp = shell_quote(SOURCE_CHANGES .. ".tmp")
4856
+ os.execute("( cd " .. shell_quote(conf.repo) .. " && find apps libs reaper knowledge scripts .claude package.json pnpm-lock.yaml"
4857
+ .. " \\( -name node_modules -o -name dist -o -name .nx -o -name __pycache__ -o -name .DS_Store -o -name '*.tsbuildinfo' -o -name '*.timestamp-*' \\) -prune"
4858
+ .. " -o -type f -newer " .. shell_quote(script_dir .. "mcp_update.conf") .. " -print -quit > " .. tmp .. " 2>/dev/null;"
4859
+ .. " mv " .. tmp .. " " .. shell_quote(SOURCE_CHANGES) .. " ) >/dev/null 2>&1 &")
4860
+ end
4861
+
4862
+ local function control_status()
4863
+ refresh_update_config()
4864
+ return {
4865
+ token = control_token,
4866
+ canRestart = can_restart,
4867
+ canUpdate = can_update,
4868
+ -- Developer mode: source changed since the last install, so Rebuild & reload has something to do
4869
+ changed = source_changed,
4870
+ update = update,
4871
+ -- The page may list, restore and save snapshots (mcp_snapshots_ui_lib.lua)
4872
+ snapshots = snaps_lib_ok,
4873
+ -- The page keeps its LoreKit tokens in ~/.config/reaper-mcp (mcp_prefs_lib.lua)
4874
+ sharedTokens = ext.prefs ~= nil,
4875
+ }
4876
+ end
4877
+
4878
+ -- Installed FX for the status page's Plugins view, in ExtState MCPBridge/installed (in memory).
4879
+ -- Re-enumerated every 60s (a few ms for ~2k plugins) and republished only when the list changed.
4880
+ local INSTALLED_FX_INTERVAL = 60
4881
+ local last_installed_fx = -INSTALLED_FX_INTERVAL
4882
+ local installed_fx_signature
4883
+
4884
+ local function publish_installed_fx(now)
4885
+ if now - last_installed_fx < INSTALLED_FX_INTERVAL then return end
4886
+ last_installed_fx = now
4887
+ local list = enumerate_installed_fx()
4888
+ local rows, names = {}, {}
4889
+ for i, fx in ipairs(list or {}) do
4890
+ rows[i] = { n = fx.name, t = fx.type }
4891
+ names[i] = fx.name
4892
+ end
4893
+ local signature = (list and "1" or "0") .. "\n" .. table.concat(names, "\n")
4894
+ if signature == installed_fx_signature then return end
4895
+ installed_fx_signature = signature
4896
+ reaper.SetExtState(STATUS_SECTION, "installed", json_encode({ v = 1, supported = list ~= nil, fx = rows }), false)
4897
+ end
4898
+
4899
+ -- FX used in the open project (track, master, input and take FX), in ExtState MCPBridge/used, so the
4900
+ -- Plugins view can show which plugins this session relies on. Recounted at most every 2s and only when
4901
+ -- the project changed (state change count or project tab); republished only when the counts changed.
4902
+ local USED_FX_INTERVAL = 2
4903
+ local last_used_fx = -USED_FX_INTERVAL
4904
+ local used_fx_state, used_fx_signature
4905
+
4906
+ -- The plugin's own name ("VST3: Pro-Q 3 (FabFilter)"), not a renamed instance
4907
+ local function original_fx_name(get_named, get_name, owner, i)
4908
+ local ok, name = get_named(owner, i, "fx_name")
4909
+ if ok and type(name) == "string" and name ~= "" then return name end
4910
+ local _, display = get_name(owner, i)
4911
+ return type(display) == "string" and display ~= "" and display or nil
4912
+ end
4913
+
4914
+ local function count_project_fx()
4915
+ local counts = {}
4916
+ local function add(name) if name then counts[name] = (counts[name] or 0) + 1 end end
4917
+ local function track_fx(track)
4918
+ if not track then return end
4919
+ for i = 0, (reaper.TrackFX_GetCount(track) or 0) - 1 do
4920
+ add(original_fx_name(reaper.TrackFX_GetNamedConfigParm, reaper.TrackFX_GetFXName, track, i))
4921
+ end
4922
+ for i = 0, (reaper.TrackFX_GetRecCount(track) or 0) - 1 do
4923
+ add(original_fx_name(reaper.TrackFX_GetNamedConfigParm, reaper.TrackFX_GetFXName, track, 0x1000000 + i))
4924
+ end
4925
+ end
4926
+ track_fx(reaper.GetMasterTrack(0))
4927
+ for t = 0, (reaper.CountTracks(0) or 0) - 1 do track_fx(reaper.GetTrack(0, t)) end
4928
+ for it = 0, (reaper.CountMediaItems(0) or 0) - 1 do
4929
+ local item = reaper.GetMediaItem(0, it)
4930
+ for k = 0, (item and reaper.CountTakes(item) or 0) - 1 do
4931
+ local take = reaper.GetTake(item, k)
4932
+ for i = 0, (take and reaper.TakeFX_GetCount(take) or 0) - 1 do
4933
+ add(original_fx_name(reaper.TakeFX_GetNamedConfigParm, reaper.TakeFX_GetFXName, take, i))
4934
+ end
4935
+ end
4936
+ end
4937
+ return counts
4938
+ end
4939
+
4940
+ local function publish_used_fx(now)
4941
+ if now - last_used_fx < USED_FX_INTERVAL then return end
4942
+ last_used_fx = now
4943
+ -- Parenthesised: exactly one value, even from an API stub that returns nothing
4944
+ local change_count = (reaper.GetProjectStateChangeCount(0))
4945
+ local state = tostring((reaper.EnumProjects(-1))) .. ":" .. tostring(change_count)
4946
+ -- An unknown change count (older REAPER, tests) means: recount every time
4947
+ if state == used_fx_state and change_count ~= nil then return end
4948
+ used_fx_state = state
4949
+ local counts = count_project_fx()
4950
+ local names = {}
4951
+ for name in pairs(counts) do names[#names + 1] = name end
4952
+ table.sort(names)
4953
+ local rows, parts = {}, {}
4954
+ for i, name in ipairs(names) do
4955
+ rows[i] = { n = name, c = counts[name] }
4956
+ parts[i] = name .. "=" .. counts[name]
4957
+ end
4958
+ local signature = table.concat(parts, "\n")
4959
+ if signature == used_fx_signature then return end
4960
+ used_fx_signature = signature
4961
+ reaper.SetExtState(STATUS_SECTION, "used", json_encode({ v = 1, fx = rows }), false)
4962
+ end
4963
+
4964
+ -- Command timeline at three resolutions, keyed by absolute bucket id (epoch_ms // size).
4965
+ -- Only buckets with commands exist, so idle time costs nothing.
4966
+ -- Each bucket: { total, errors, slow (ok, >= HISTORY_SLOW_MS, not LONG_RUNNING), summed handler ms }.
4967
+ -- The 10s tier is published live in ExtState `history`; the 1m and 10m tiers (for the page's
4968
+ -- day and week ranges) in `history_long`, at most every 10s. All tiers persist in history.json,
4969
+ -- saved every 30s and on exit, so the timeline survives a bridge or REAPER restart.
4970
+ local HISTORY_TIERS = {
4971
+ { size = 10000, keep = 3600 * 1000 },
4972
+ { size = 60000, keep = 24 * 3600 * 1000 },
4973
+ { size = 600000, keep = 7 * 24 * 3600 * 1000 },
4974
+ }
4975
+ for _, tier in ipairs(HISTORY_TIERS) do tier.b = {} end
4976
+ local HISTORY_SLOW_MS = 100
4977
+ -- Commands whose duration is the work itself (renders, plugin loads, project IO, proxies that run
4978
+ -- arbitrary work): never counted as slow. Mirrors LONG_RUNNING in apps/bridge-ui/src/lib/commands.ts.
4979
+ local LONG_RUNNING = {}
4980
+ for _, c in ipairs({
4981
+ "render_project", "render_track_to_wav", "render_tracks_to_wav", "render_versions", "run_plan", "run_action", "save_project", "undo_project",
4982
+ "redo_project", "add_fx", "add_take_fx", "setup_fx_chain", "copy_fx", "move_fx", "set_fx_offline",
4983
+ "set_fx_preset", "get_fx_preset_list", "list_available_fx", "load_fx_chain", "insert_track_template",
4984
+ "insert_audio_file", "snapshot_save", "snapshot_restore", "snapshot_edit",
4985
+ "analyze_track_offline", "analyze_audio_file", "measure_track_alignment", "verify_edit", "analyze_track_aesthetics",
4986
+ "detect_transients", "create_transient_automation", "insert_stem_tracks",
4987
+ "analyze_loudness_curve", "analyze_stereo_bands", "measure_masking", "separate_stems",
4988
+ "transcribe_voice_sketch",
4989
+ "freeze_tracks", "unfreeze_tracks", "apply_fx_to_items", "quantize_audio", "align_audio_to_guide", "detect_key", "detect_chords", "import_midi_file", "process_items",
4990
+ }) do LONG_RUNNING[c] = true end
4991
+ local HISTORY_MIN_PUBLISH_INTERVAL = 1.0
4992
+ local HISTORY_LONG_PUBLISH_INTERVAL = 10
4993
+ local HISTORY_SAVE_INTERVAL = 30
4994
+ local HISTORY_FILE = bridge_dir .. "history.json"
4995
+ local history_dirty, history_long_dirty, history_unsaved = false, false, false
4996
+ -- Last command sequence number, saved with the timeline: the log alone forgets it after a week idle
4997
+ local saved_seq = 0
4998
+ local last_history_publish, last_history_long_publish, last_history_save =
4999
+ -HISTORY_MIN_PUBLISH_INTERVAL, -HISTORY_LONG_PUBLISH_INTERVAL, 0
5000
+
5001
+ local function record_history(handler_ms, ok, cmd_type, failed)
5002
+ local slow = ok and handler_ms >= HISTORY_SLOW_MS and not LONG_RUNNING[cmd_type]
5003
+ local t = now_ms()
5004
+ for _, tier in ipairs(HISTORY_TIERS) do
5005
+ local id = math.floor(t / tier.size)
5006
+ local b = tier.b[id]
5007
+ if not b then
5008
+ b = { 0, 0, 0, 0 }
5009
+ tier.b[id] = b
5010
+ end
5011
+ b[1] = b[1] + 1
5012
+ if failed then b[2] = b[2] + 1 end
5013
+ if slow then b[3] = b[3] + 1 end -- slow successes; errors counted apart
5014
+ b[4] = b[4] + handler_ms
5015
+ end
5016
+ history_dirty, history_long_dirty, history_unsaved = true, true, true
5017
+ end
5018
+
5019
+ -- Rows `[bucketId, total, errors, slow, sumMs]`, oldest first; drops buckets older than the tier keeps
5020
+ local function tier_rows(tier)
5021
+ local oldest = math.floor(now_ms() / tier.size) - tier.keep // tier.size + 1
5022
+ local ids = {}
5023
+ for id in pairs(tier.b) do
5024
+ if id < oldest then tier.b[id] = nil else ids[#ids + 1] = id end
5025
+ end
5026
+ table.sort(ids)
5027
+ local rows = {}
5028
+ for i, id in ipairs(ids) do
5029
+ local b = tier.b[id]
5030
+ rows[i] = { id, b[1], b[2], b[3], math.floor(b[4] * 10 + 0.5) / 10 }
5031
+ end
5032
+ return rows
5033
+ end
5034
+
5035
+ local function publish_history(now)
5036
+ if history_dirty and now - last_history_publish >= HISTORY_MIN_PUBLISH_INTERVAL then
5037
+ last_history_publish = now
5038
+ history_dirty = false
5039
+ -- b: [bucketId, total, errors, slow, sumMs]; bucket start = bucketId * size
5040
+ local tier = HISTORY_TIERS[1]
5041
+ reaper.SetExtState(STATUS_SECTION, "history", json_encode({ v = 1, size = tier.size, b = tier_rows(tier) }), false)
5042
+ end
5043
+ if history_long_dirty and now - last_history_long_publish >= HISTORY_LONG_PUBLISH_INTERVAL then
5044
+ last_history_long_publish = now
5045
+ history_long_dirty = false
5046
+ local tiers = {}
5047
+ for i = 2, #HISTORY_TIERS do
5048
+ tiers[#tiers + 1] = { size = HISTORY_TIERS[i].size, b = tier_rows(HISTORY_TIERS[i]) }
5049
+ end
5050
+ reaper.SetExtState(STATUS_SECTION, "history_long", json_encode({ v = 1, tiers = tiers }), false)
5051
+ end
5052
+ end
5053
+
5054
+ local function save_history(now, force)
5055
+ if not history_unsaved or (not force and now - last_history_save < HISTORY_SAVE_INTERVAL) then return end
5056
+ last_history_save = now
5057
+ history_unsaved = false
5058
+ local tiers = {}
5059
+ for i, tier in ipairs(HISTORY_TIERS) do tiers[i] = { size = tier.size, b = tier_rows(tier) } end
5060
+ write_file(HISTORY_FILE, json_encode({ v = 1, seq = status.seq, tiers = tiers }))
5061
+ end
5062
+
5063
+ local function load_history()
5064
+ local content = read_file(HISTORY_FILE)
5065
+ if not content or not plan_lib_ok then return end
5066
+ local ok, saved = pcall(plan_lib.json_decode, content)
5067
+ if not ok or type(saved) ~= "table" or type(saved.tiers) ~= "table" then return end
5068
+ if type(saved.seq) == "number" then saved_seq = saved.seq end
5069
+ for _, st in ipairs(saved.tiers) do
5070
+ for _, tier in ipairs(HISTORY_TIERS) do
5071
+ if type(st) == "table" and st.size == tier.size and type(st.b) == "table" then
5072
+ for _, r in ipairs(st.b) do
5073
+ if type(r) == "table" and #r >= 5 then tier.b[r[1]] = { r[2], r[3], r[4], r[5] } end
5074
+ end
5075
+ end
5076
+ end
5077
+ end
5078
+ for _, tier in ipairs(HISTORY_TIERS) do tier_rows(tier) end -- drop what aged out while we were stopped
5079
+ history_dirty, history_long_dirty = true, true
5080
+ end
5081
+
5082
+ -- Command log: every logged command is appended to history_log.jsonl (like bridge_events.jsonl)
5083
+ -- and reloaded on start, so the page's log survives a restart. Kept to the newest LOG_KEEP_MAX
5084
+ -- entries within the week the timeline covers; the file is compacted on start and whenever it
5085
+ -- has grown by LOG_KEEP_MAX lines. Published as ExtState `log` (newest first) only when the page
5086
+ -- asks by writing ExtState `log_want` (it does on open, after a restart and on a gap): encoding a
5087
+ -- thousand entries on every change would cost the main thread for a key that is rarely read.
5088
+ local LOG_FILE = bridge_dir .. "history_log.jsonl"
5089
+ local LOG_KEEP_MAX = 1000
5090
+ local LOG_KEEP_MS = HISTORY_TIERS[#HISTORY_TIERS].keep
5091
+ local saved_log = {} -- newest first
5092
+ local log_appends = 0
5093
+ local last_log_request = ""
5094
+
5095
+ local function compact_log()
5096
+ local lines = {}
5097
+ for i = #saved_log, 1, -1 do lines[#lines + 1] = json_encode(saved_log[i]) end
5098
+ write_file(LOG_FILE, #lines > 0 and table.concat(lines, "\n") .. "\n" or "")
5099
+ log_appends = 0
5100
+ end
5101
+
5102
+ local function load_log()
5103
+ local content = read_file(LOG_FILE)
5104
+ if not content or not plan_lib_ok then return end
5105
+ local cutoff = now_ms() - LOG_KEEP_MS
5106
+ local entries = {} -- oldest first, as written
5107
+ for line in content:gmatch("[^\n]+") do
5108
+ local ok, e = pcall(plan_lib.json_decode, line)
5109
+ if ok and type(e) == "table" and type(e.n) == "number" and type(e.t) == "number" and e.t >= cutoff then
5110
+ entries[#entries + 1] = e
5111
+ end
5112
+ end
5113
+ for i = #entries, math.max(1, #entries - LOG_KEEP_MAX + 1), -1 do saved_log[#saved_log + 1] = entries[i] end
5114
+ compact_log()
5115
+ end
5116
+
5117
+ local function append_log(entry)
5118
+ table.insert(saved_log, 1, entry)
5119
+ saved_log[LOG_KEEP_MAX + 1] = nil
5120
+ log_appends = log_appends + 1
5121
+ if log_appends >= LOG_KEEP_MAX then return compact_log() end
5122
+ local f = io.open(LOG_FILE, "a")
5123
+ if not f then return end
5124
+ f:write(json_encode(entry) .. "\n")
5125
+ f:close()
5126
+ end
5127
+
5128
+ local function publish_log()
5129
+ local req = reaper.GetExtState(STATUS_SECTION, "log_want")
5130
+ if req == "" or req == last_log_request then return end
5131
+ last_log_request = req
5132
+ -- req echoes the page's request so it can tell this answer from an older one
5133
+ reaper.SetExtState(STATUS_SECTION, "log", json_encode({ v = 1, req = req, entries = saved_log }), false)
5134
+ end
5135
+
5136
+ load_history()
5137
+ load_log()
5138
+ -- Sequence numbers continue across restarts so the page can merge the saved log with live entries
5139
+ status.seq = math.max(saved_seq, saved_log[1] and saved_log[1].n or 0)
5140
+ for i = 1, math.min(STATUS_RECENT_MAX, #saved_log) do status.recent[i] = saved_log[i] end
5141
+
5142
+ local STATUS_PARAMS_MAX = 400
5143
+
5144
+ -- Measurement results and edit views are kept on the log entry (`r`) so the status page can draw them
5145
+ -- (mcp_edit_views_lib.lua); everything else logs params only.
5146
+ if edit_lib_ok then
5147
+ edit_lib.init({
5148
+ api = reaper, to_db = to_db, json_decode = plan_lib_ok and plan_lib.json_decode or nil, json_decode_array = json_decode_array,
5149
+ get_midi_notes = function(p) return handlers.get_midi_notes(p) end, get_midi_take = get_midi_take,
5150
+ get_midi_cc = function(p) return handlers.get_midi_cc(p) end,
5151
+ recording = not ext.recording.load_error and ext.recording or nil,
5152
+ resolve_track = function(ref)
5153
+ local res = plan_lib_ok and plan_lib.resolve_track(reaper, ref, {}) or nil
5154
+ return res and res.track or nil
5155
+ end,
5156
+ })
5157
+ end
5158
+
5159
+ -- Results of tools the server composes itself (sidecar analysis), sent as `_log_result` so the page shows them
5160
+ -- like any command: c = the tool, its own duration, and `r` (a JSON string) for the measurement views.
5161
+ local STATUS_RESULT_MAX = 6000
5162
+ local function record_tool_result(cmd, session)
5163
+ local p = cmd.params
5164
+ local entry = {
5165
+ n = status.seq + 1, t = now_ms(), c = p.command:sub(1, 64), ms = math.floor((tonumber(p.ms) or 0) * 100 + 0.5) / 100,
5166
+ ok = p.ok ~= 0 and p.ok ~= false,
5167
+ }
5168
+ if session then entry.s = session.id end
5169
+ if type(p.p) == "string" and p.p ~= "" and p.p ~= "{}" then entry.p = p.p:sub(1, STATUS_PARAMS_MAX) end
5170
+ if type(p.e) == "string" then entry.e = p.e:gsub("%c", " "):sub(1, 300) end
5171
+ if type(p.r) == "string" and #p.r <= STATUS_RESULT_MAX and plan_lib_ok then
5172
+ local got, r = pcall(plan_lib.json_decode, p.r)
5173
+ if got and type(r) == "table" then entry.r = r end
5174
+ end
5175
+ status.commands = status.commands + 1
5176
+ status.seq = status.seq + 1
5177
+ table.insert(status.recent, 1, entry)
5178
+ status.recent[STATUS_RECENT_MAX + 1] = nil
5179
+ status_dirty = true
5180
+ record_history(entry.ms, entry.ok, entry.c, false)
5181
+ append_log(entry)
5182
+ end
5183
+
5184
+ local function record_command(cmd, handler_ms, ok, err, data, before)
5185
+ local cmd_type = cmd.type
5186
+ -- Internal commands (e.g. the server's _bridge_diagnostics poller) are noise, but still mark the session alive
5187
+ if type(cmd_type) ~= "string" or cmd_type:sub(1, 1) == "_" then
5188
+ local s = touch_session(cmd, nil)
5189
+ if s and cmd_type == "_session" and type(cmd.params) == "table" and cmd.params.ended then s.ended = true end
5190
+ if cmd_type == "_log_result" and ok and type(cmd.params) == "table" and type(cmd.params.command) == "string" then
5191
+ record_tool_result(cmd, s)
5192
+ end
5193
+ return
5194
+ end
5195
+ -- Only system errors count as errors. A refused request ("Track 9 not found") is a
5196
+ -- warning: the caller asked for something that can't be done. Same prefixes as
5197
+ -- classifyBridgeError() in bridge.ts and SYSTEM_ERROR_PREFIXES in the page's lib/commands.ts.
5198
+ local e_str = err and tostring(err) or ""
5199
+ local failed = not ok and (e_str:find("^Handler error:") or e_str:find("^Unknown command type")
5200
+ or e_str:find("^Internal error:")) ~= nil
5201
+ local session = touch_session(cmd, ok, failed)
5202
+ status.commands = status.commands + 1
5203
+ status.seq = status.seq + 1
5204
+ if failed then status.errors = status.errors + 1 end
5205
+ local entry = { n = status.seq, t = now_ms(), c = cmd_type, ms = math.floor(handler_ms * 100 + 0.5) / 100, ok = ok }
5206
+ if session then entry.s = session.id end
5207
+ if type(cmd.params) == "table" and next(cmd.params) ~= nil then
5208
+ -- Decode arrays the flat command parser left as JSON text, as the handlers do, so they log as JSON
5209
+ local logged = {}
5210
+ for k, v in pairs(cmd.params) do
5211
+ logged[k] = type(v) == "string" and v:find("^%s*[%[{]") and decode_list(v, k) or v
5212
+ end
5213
+ entry.p = json_encode(logged):sub(1, STATUS_PARAMS_MAX)
5214
+ end
5215
+ if err then
5216
+ entry.e = tostring(err):gsub("%c", " "):sub(1, 300)
5217
+ end
5218
+ -- Sent from the status page (a snapshot restore or save), not by an agent
5219
+ if cmd.source == "page" then entry.src = "page" end
5220
+ if ok and edit_lib_ok then
5221
+ local got, r = pcall(edit_lib.summary, cmd_type, cmd.params or {}, data, before)
5222
+ entry.r = got and r or nil
5223
+ end
5224
+ table.insert(status.recent, 1, entry)
5225
+ status.recent[STATUS_RECENT_MAX + 1] = nil
5226
+ status_dirty = true
5227
+ record_history(handler_ms, ok, cmd_type, failed)
5228
+ append_log(entry)
5229
+ end
5230
+
5231
+ -- cfg_open_on_start ("1"/"0") overrides OPEN_UI_ON_START once the user has chosen in the page
5232
+ local function open_on_start()
5233
+ local v = reaper.GetExtState(STATUS_SECTION, "cfg_open_on_start")
5234
+ if v == "" then return OPEN_UI_ON_START end
5235
+ return v == "1"
5236
+ end
5237
+
5238
+ -- REAPER loads only csurf_0 … csurf_<cnt-1>; an HTTP line past the count is a leftover it ignores
5239
+ local function find_web_port()
5240
+ local ini = read_file(reaper.get_ini_file())
5241
+ if not ini then return nil end
5242
+ local active = tonumber(ini:match("\ncsurf_cnt=(%d+)")) or 0
5243
+ for i, port in ini:gmatch("\ncsurf_(%d+)=HTTP %S+ (%d+)") do
5244
+ if tonumber(i) < active then return port end
5245
+ end
5246
+ end
5247
+
5248
+ -- REAPER's web server never frees a socket whose client hung up first: it stays in CLOSE_WAIT or CLOSED, and
5249
+ -- at about 100 the server stops answering until REAPER restarts. Count them every 30 s so the page can warn
5250
+ -- before that, with lsof (~50 ms) in the background so the defer loop never waits. macOS and Linux only.
5251
+ local web_watch = { interval = 30, limit = 100, last = -30, file = bridge_dir .. "web_sockets" }
5252
+ os.remove(web_watch.file) -- a count left by an earlier REAPER run describes a server that is gone
5253
+
5254
+ function web_watch.check(now)
5255
+ if is_windows or now - web_watch.last < web_watch.interval then return end
5256
+ web_watch.last = now
5257
+ web_watch.port = web_watch.port or find_web_port()
5258
+ if not web_watch.port then return end
5259
+ local stuck = tonumber((read_file(web_watch.file) or ""):match("%d+"))
5260
+ if stuck and stuck ~= (web_watch.status and web_watch.status.stuck) then
5261
+ web_watch.status = { stuck = stuck, limit = web_watch.limit }
5262
+ status_dirty = true
5263
+ if stuck >= web_watch.limit * 0.9 and not web_watch.warned then
5264
+ web_watch.warned = true
5265
+ reaper.ShowConsoleMsg(string.format("MCP Bridge: REAPER's web server holds %d of about %d connections it never "
5266
+ .. "freed, so the status page will stop loading. Restart REAPER to clear them.\n", stuck, web_watch.limit))
5267
+ end
5268
+ end
5269
+ local tmp = shell_quote(web_watch.file .. ".tmp")
5270
+ os.execute("(lsof -nP -iTCP:" .. web_watch.port .. " -sTCP:CLOSE_WAIT,CLOSED 2>/dev/null"
5271
+ .. " | awk 'NR > 1 && tolower($1) == \"reaper\"' | wc -l > " .. tmp
5272
+ .. " && mv " .. tmp .. " " .. shell_quote(web_watch.file) .. ") >/dev/null 2>&1 &")
5273
+ end
5274
+
5275
+ local function publish_status(now, force)
5276
+ if not force and (not status_dirty or now - last_status_publish < STATUS_MIN_PUBLISH_INTERVAL) then
3859
5277
  return
3860
5278
  end
5279
+ last_status_publish = now
5280
+ status_dirty = false
5281
+
5282
+ local defer_sum, defer_n = 0, math.min(defer_count, defer_buf_size)
5283
+ for i = 1, defer_n do defer_sum = defer_sum + defer_intervals[i] end
3861
5284
 
5285
+ reaper.SetExtState(STATUS_SECTION, "status", json_encode({
5286
+ v = 1,
5287
+ ts = now_ms(),
5288
+ startedAt = status.startedAt,
5289
+ reaperVersion = reaper.GetAppVersion(),
5290
+ project = select(2, reaper.EnumProjects(-1)) or "",
5291
+ bridgeDir = bridge_dir,
5292
+ commands = status.commands,
5293
+ errors = status.errors,
5294
+ deferMs = defer_n > 0 and math.floor(defer_sum / defer_n * 100000 + 0.5) / 100 or 0,
5295
+ recent = status.recent,
5296
+ sessions = session_list(),
5297
+ settings = { paused = cfg("cfg_paused"), readonly = cfg("cfg_readonly"), openOnStart = open_on_start(), dev = dev_mode() },
5298
+ health = health,
5299
+ web = web_watch.status,
5300
+ control = control_status(),
5301
+ }), false)
5302
+ end
5303
+
5304
+ -- =============================================================================
5305
+ -- Command dispatcher
5306
+ -- =============================================================================
5307
+
5308
+ -- Runs one command (from a command file, or a status-page request) and logs it; returns the response
5309
+ local function run_command(cmd)
3862
5310
  -- Dispatch to handler
3863
5311
  local handler = handlers[cmd.type]
3864
5312
  local response = {}
3865
5313
  local handler_ms = 0
3866
-
3867
- if handler then
5314
+ local before -- what a write is about to change, for its edit view
5315
+
5316
+ local internal = type(cmd.type) == "string" and cmd.type:sub(1, 1) == "_"
5317
+ local blocked
5318
+ if (not internal or ext.paused_too[cmd.type]) and cfg("cfg_paused") then
5319
+ blocked = "Paused by user in the MCP Bridge window. Ask the user to resume agent control."
5320
+ elseif not internal and cfg("cfg_readonly") and not readonly_allows(cmd.type) then
5321
+ blocked = "Read-only mode is on in the MCP Bridge window: '" .. tostring(cmd.type)
5322
+ .. "' would change the project. Ask the user to turn read-only off."
5323
+ end
5324
+
5325
+ if blocked then
5326
+ response = { id = cmd.id, success = false, error = blocked, timestamp = os.time() * 1000 }
5327
+ elseif handler then
5328
+ if edit_lib_ok then
5329
+ local got, snapshot = pcall(edit_lib.before, cmd.type, cmd.params or {})
5330
+ before = got and snapshot or nil
5331
+ end
3868
5332
  local handler_start = reaper.time_precise()
3869
5333
  local ok, data_or_err, err_msg = pcall(handler, cmd.params or {})
3870
5334
  handler_ms = (reaper.time_precise() - handler_start) * 1000
5335
+ -- A command that may have changed the project makes every cached render stale
5336
+ if ext.render.ok and not internal and not readonly_allows(cmd.type) then ext.render.lib.note_write() end
3871
5337
 
3872
5338
  if not ok then
3873
5339
  -- pcall caught a Lua runtime error — handler crashed
@@ -3894,6 +5360,26 @@ local function process_command(filename)
3894
5360
  -- Add handler timing to response (rounded to microsecond precision)
3895
5361
  response._handlerMs = math.floor(handler_ms * 1000 + 0.5) / 1000
3896
5362
 
5363
+ record_command(cmd, handler_ms, response.success, response.error, response.data, before)
5364
+ if snaps_lib_ok then snaps_lib.after_command(cmd, response) end
5365
+ return response
5366
+ end
5367
+
5368
+ local function process_command(filename)
5369
+ local pickup_time = reaper.time_precise()
5370
+ local path = bridge_dir .. filename
5371
+ local content = read_file(path)
5372
+ if not content then return end
5373
+
5374
+ local cmd = json_decode(content)
5375
+ if not cmd or not cmd.id or not cmd.type then
5376
+ -- Invalid command, remove file
5377
+ os.remove(path)
5378
+ return
5379
+ end
5380
+
5381
+ local response = run_command(cmd)
5382
+
3897
5383
  -- Add pickup timing to response for profiling
3898
5384
  if cmd.timestamp then
3899
5385
  response._pickupMs = math.floor((pickup_time - (cmd.timestamp / 1000)) * 1000 + 0.5)
@@ -3909,6 +5395,27 @@ local function process_command(filename)
3909
5395
  os.remove(path)
3910
5396
  end
3911
5397
 
5398
+ if snaps_lib_ok then
5399
+ snaps_lib.init({
5400
+ api = reaper, section = STATUS_SECTION, dir = get_snapshot_dir, storage = get_snapshot_storage_location,
5401
+ decode = plan_lib_ok and plan_lib.json_decode or json_decode, encode = json_encode, read_file = read_file,
5402
+ token = function() return control_token end, run = run_command,
5403
+ })
5404
+ snaps_lib.register_actions(reaper, script_dir, read_kb_ini())
5405
+ end
5406
+
5407
+ if ext.prefs then
5408
+ local windows = reaper.GetOS and tostring(reaper.GetOS()):find("Win") ~= nil
5409
+ ext.prefs.init({
5410
+ api = reaper, section = STATUS_SECTION, dir = ext.prefs.default_dir(os.getenv, windows),
5411
+ decode = plan_lib_ok and plan_lib.json_decode or json_decode, encode = json_encode, read_file = read_file,
5412
+ token = function() return control_token end,
5413
+ chmod = not windows and function(path)
5414
+ if not path:find("'", 1, true) then os.execute("chmod 600 '" .. path .. "'") end
5415
+ end or nil,
5416
+ })
5417
+ end
5418
+
3912
5419
  -- =============================================================================
3913
5420
  -- Heartbeat
3914
5421
  -- =============================================================================
@@ -3955,7 +5462,21 @@ local function main_loop()
3955
5462
  if now - last_heartbeat >= HEARTBEAT_INTERVAL then
3956
5463
  last_heartbeat = now
3957
5464
  write_heartbeat()
3958
- end
5465
+ publish_status(now, true)
5466
+ else
5467
+ publish_status(now, false)
5468
+ end
5469
+ publish_history(now)
5470
+ save_history(now, false)
5471
+ publish_log()
5472
+ check_health(now)
5473
+ web_watch.check(now)
5474
+ check_control(now)
5475
+ check_source_changes(now)
5476
+ publish_installed_fx(now)
5477
+ publish_used_fx(now)
5478
+ if snaps_lib_ok then snaps_lib.poll(now) end
5479
+ if ext.prefs then ext.prefs.poll() end
3959
5480
 
3960
5481
  reaper.defer(main_loop)
3961
5482
  end
@@ -3964,9 +5485,42 @@ end
3964
5485
  -- Startup
3965
5486
  -- =============================================================================
3966
5487
 
3967
- reaper.ShowConsoleMsg("MCP Bridge: Started\n")
3968
- reaper.ShowConsoleMsg("MCP Bridge: Bridge directory: " .. bridge_dir .. "\n")
3969
- reaper.ShowConsoleMsg("MCP Bridge: Polling every " .. (POLL_INTERVAL * 1000) .. "ms\n")
5488
+ -- Find the REAPER web interface port (reaper.ini: csurf_N=HTTP <flags> <port> ...)
5489
+ -- The page pings ExtState ui_seen on every poll; a recent ping means it's already open
5490
+ local function ui_recently_seen()
5491
+ local seen = tonumber(reaper.GetExtState(STATUS_SECTION, "ui_seen"))
5492
+ return seen ~= nil and math.abs(os.time() * 1000 - seen) < 6000
5493
+ end
5494
+
5495
+ -- Open the status page as a small chromeless app window (Chromium --app), else the default browser
5496
+ local function open_status_window(url)
5497
+ local os_name = reaper.GetOS()
5498
+ local cmd
5499
+ if os_name:match("^OSX") or os_name:match("^macOS") then
5500
+ for _, app in ipairs({ "Google Chrome", "Microsoft Edge", "Brave Browser", "Chromium" }) do
5501
+ local bin = "/Applications/" .. app .. ".app/Contents/MacOS/" .. app
5502
+ if file_exists(bin) then
5503
+ cmd = shell_quote(bin) .. " --app=" .. shell_quote(url) .. " --window-size=980,720 >/dev/null 2>&1 &"
5504
+ break
5505
+ end
5506
+ end
5507
+ cmd = cmd or ("open " .. shell_quote(url))
5508
+ elseif os_name:match("^Win") then
5509
+ cmd = 'start "" msedge --app="' .. url .. '" --window-size=980,720'
5510
+ else
5511
+ local q = shell_quote(url)
5512
+ cmd = "(google-chrome --app=" .. q .. " || chromium --app=" .. q .. " || xdg-open " .. q .. ") >/dev/null 2>&1 &"
5513
+ end
5514
+ os.execute(cmd)
5515
+ end
5516
+
5517
+ local web_port = find_web_port()
5518
+ if not web_port then
5519
+ reaper.ShowConsoleMsg("MCP Bridge: running. For the status window, quit REAPER and run `reaper-mcp setup` "
5520
+ .. "(or enable Preferences > Control/OSC/Web > Web browser interface).\n")
5521
+ elseif open_on_start() and not ui_recently_seen() then
5522
+ open_status_window("http://localhost:" .. web_port .. "/mcp_bridge.html?app=window")
5523
+ end
3970
5524
 
3971
5525
  -- Write initial heartbeat
3972
5526
  write_heartbeat()
@@ -3975,7 +5529,16 @@ write_heartbeat()
3975
5529
  log_event("bridge_start", { reaperVersion = reaper.GetAppVersion(), bridgeDir = bridge_dir })
3976
5530
 
3977
5531
  -- Register shutdown handler
3978
- reaper.atexit(function() log_event("bridge_stop", {}) end)
5532
+ -- Renders a previous run left behind
5533
+ if ext.render.ok then pcall(ext.render.lib.sweep, reaper) end
5534
+
5535
+ reaper.atexit(function()
5536
+ if ext.render.ok then pcall(ext.render.lib.purge) end
5537
+ log_event("bridge_stop", {})
5538
+ save_history(reaper.time_precise(), true)
5539
+ reaper.DeleteExtState(STATUS_SECTION, "status", false) -- UI flips to offline at once
5540
+ for _, key in ipairs({ "history", "history_long", "log" }) do reaper.DeleteExtState(STATUS_SECTION, key, false) end
5541
+ end)
3979
5542
 
3980
5543
  -- Start the loop
3981
5544
  main_loop()