@mthines/reaper-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +281 -0
  2. package/claude-rules/architecture.md +39 -0
  3. package/claude-rules/development.md +54 -0
  4. package/claude-rules/lua-bridge.md +50 -0
  5. package/claude-rules/testing.md +42 -0
  6. package/claude-skills/learn-plugin.md +123 -0
  7. package/knowledge/genres/_template.md +109 -0
  8. package/knowledge/genres/electronic.md +112 -0
  9. package/knowledge/genres/hip-hop.md +111 -0
  10. package/knowledge/genres/metal.md +136 -0
  11. package/knowledge/genres/orchestral.md +132 -0
  12. package/knowledge/genres/pop.md +108 -0
  13. package/knowledge/genres/rock.md +117 -0
  14. package/knowledge/plugins/_template.md +82 -0
  15. package/knowledge/plugins/fabfilter/pro-c-2.md +117 -0
  16. package/knowledge/plugins/fabfilter/pro-l-2.md +95 -0
  17. package/knowledge/plugins/fabfilter/pro-q-3.md +112 -0
  18. package/knowledge/plugins/neural-dsp/helix-native.md +104 -0
  19. package/knowledge/plugins/stock-reaper/js-1175-compressor.md +94 -0
  20. package/knowledge/plugins/stock-reaper/rea-comp.md +100 -0
  21. package/knowledge/plugins/stock-reaper/rea-delay.md +95 -0
  22. package/knowledge/plugins/stock-reaper/rea-eq.md +103 -0
  23. package/knowledge/plugins/stock-reaper/rea-gate.md +99 -0
  24. package/knowledge/plugins/stock-reaper/rea-limit.md +75 -0
  25. package/knowledge/plugins/stock-reaper/rea-verb.md +76 -0
  26. package/knowledge/reference/common-mistakes.md +307 -0
  27. package/knowledge/reference/compression.md +176 -0
  28. package/knowledge/reference/frequencies.md +154 -0
  29. package/knowledge/reference/metering.md +166 -0
  30. package/knowledge/workflows/drum-bus.md +211 -0
  31. package/knowledge/workflows/gain-staging.md +165 -0
  32. package/knowledge/workflows/low-end.md +261 -0
  33. package/knowledge/workflows/master-bus.md +204 -0
  34. package/knowledge/workflows/vocal-chain.md +246 -0
  35. package/main.js +755 -0
  36. package/package.json +44 -0
  37. package/reaper/install.sh +50 -0
  38. package/reaper/mcp_analyzer.jsfx +167 -0
  39. package/reaper/mcp_bridge.lua +1105 -0
  40. package/reaper/mcp_correlation_meter.jsfx +148 -0
  41. package/reaper/mcp_crest_factor.jsfx +108 -0
  42. package/reaper/mcp_lufs_meter.jsfx +301 -0
@@ -0,0 +1,1105 @@
1
+ -- =============================================================================
2
+ -- MCP Bridge for REAPER
3
+ -- =============================================================================
4
+ -- This script runs as a persistent defer loop inside REAPER.
5
+ -- It polls a bridge directory for JSON command files from the MCP server,
6
+ -- executes the corresponding ReaScript API calls, and writes response files.
7
+ --
8
+ -- Install: Actions > Show action list > Load ReaScript > select this file > Run
9
+ -- The script will keep running in the background via defer().
10
+ -- =============================================================================
11
+
12
+ local POLL_INTERVAL = 0.030 -- 30ms between polls
13
+ local HEARTBEAT_INTERVAL = 1.0 -- write heartbeat every 1s
14
+ local MCP_ANALYZER_FX_NAME = "mcp_analyzer" -- JSFX analyzer name
15
+
16
+ -- Determine bridge directory (REAPER resource path / Scripts / mcp_bridge_data)
17
+ local bridge_dir = reaper.GetResourcePath() .. "/Scripts/mcp_bridge_data/"
18
+
19
+ -- Ensure bridge directory exists
20
+ reaper.RecursiveCreateDirectory(bridge_dir, 0)
21
+
22
+ local last_poll = 0
23
+ local last_heartbeat = 0
24
+
25
+ -- =============================================================================
26
+ -- JSON Parser (minimal, sufficient for our command format)
27
+ -- =============================================================================
28
+
29
+ local function json_decode(str)
30
+ -- Use REAPER 7+ built-in JSON if available, otherwise basic parser
31
+ if reaper.CF_Json_Parse then
32
+ local ok, val = reaper.CF_Json_Parse(str)
33
+ if ok then return val end
34
+ end
35
+
36
+ -- Fallback: use Lua pattern matching for our simple JSON format
37
+ -- This handles the flat command objects we receive
38
+ local obj = {}
39
+ -- Extract string values: "key": "value"
40
+ for k, v in str:gmatch('"([^"]+)"%s*:%s*"([^"]*)"') do
41
+ obj[k] = v
42
+ end
43
+ -- Extract number values: "key": 123.456
44
+ for k, v in str:gmatch('"([^"]+)"%s*:%s*(-?%d+%.?%d*)') do
45
+ if not obj[k] then obj[k] = tonumber(v) end
46
+ end
47
+ -- Extract nested params object
48
+ local params_str = str:match('"params"%s*:%s*(%b{})')
49
+ if params_str then
50
+ obj.params = {}
51
+ for k, v in params_str:gmatch('"([^"]+)"%s*:%s*"([^"]*)"') do
52
+ obj.params[k] = v
53
+ end
54
+ for k, v in params_str:gmatch('"([^"]+)"%s*:%s*(-?%d+%.?%d*)') do
55
+ if not obj.params[k] then obj.params[k] = tonumber(v) end
56
+ end
57
+ end
58
+ return obj
59
+ end
60
+
61
+ local function json_encode(obj)
62
+ -- Simple JSON encoder for our response objects
63
+ local parts = {}
64
+ local function encode_value(v)
65
+ local t = type(v)
66
+ if t == "string" then
67
+ return '"' .. v:gsub('\\', '\\\\'):gsub('"', '\\"'):gsub('\n', '\\n') .. '"'
68
+ elseif t == "number" then
69
+ if v ~= v then return "null" end -- NaN
70
+ if v == math.huge or v == -math.huge then return "null" end
71
+ return tostring(v)
72
+ elseif t == "boolean" then
73
+ return tostring(v)
74
+ elseif t == "table" then
75
+ -- Check if array or object
76
+ if #v > 0 or next(v) == nil then
77
+ -- Array (or empty table treated as empty array)
78
+ local items = {}
79
+ for i, item in ipairs(v) do
80
+ items[i] = encode_value(item)
81
+ end
82
+ return "[" .. table.concat(items, ",") .. "]"
83
+ else
84
+ -- Object
85
+ local items = {}
86
+ for k, val in pairs(v) do
87
+ items[#items + 1] = '"' .. tostring(k) .. '":' .. encode_value(val)
88
+ end
89
+ return "{" .. table.concat(items, ",") .. "}"
90
+ end
91
+ elseif t == "nil" then
92
+ return "null"
93
+ end
94
+ return "null"
95
+ end
96
+ return encode_value(obj)
97
+ end
98
+
99
+ -- =============================================================================
100
+ -- File I/O helpers
101
+ -- =============================================================================
102
+
103
+ local function read_file(path)
104
+ local f = io.open(path, "r")
105
+ if not f then return nil end
106
+ local content = f:read("*a")
107
+ f:close()
108
+ return content
109
+ end
110
+
111
+ local function write_file(path, content)
112
+ local f = io.open(path, "w")
113
+ if not f then return false end
114
+ f:write(content)
115
+ f:close()
116
+ return true
117
+ end
118
+
119
+ local function file_exists(path)
120
+ local f = io.open(path, "r")
121
+ if f then f:close() return true end
122
+ return false
123
+ end
124
+
125
+ local function list_files(dir, prefix)
126
+ local files = {}
127
+ local i = 0
128
+ while true do
129
+ local fn = reaper.EnumerateFiles(dir, i)
130
+ if not fn then break end
131
+ if prefix and fn:sub(1, #prefix) == prefix then
132
+ files[#files + 1] = fn
133
+ end
134
+ i = i + 1
135
+ end
136
+ return files
137
+ end
138
+
139
+ -- =============================================================================
140
+ -- dB conversion helpers
141
+ -- =============================================================================
142
+
143
+ local function to_db(val)
144
+ if val <= 0 then return -150.0 end
145
+ return 20 * math.log(val, 10)
146
+ end
147
+
148
+ local function from_db(db)
149
+ return 10 ^ (db / 20)
150
+ end
151
+
152
+ -- =============================================================================
153
+ -- Command handlers
154
+ -- =============================================================================
155
+
156
+ local handlers = {}
157
+
158
+ function handlers.get_project_info(params)
159
+ local _, proj_name = reaper.EnumProjects(-1)
160
+ local proj_path = reaper.GetProjectPath()
161
+ local track_count = reaper.CountTracks(0)
162
+ local tempo = reaper.Master_GetTempo()
163
+ local _, ts_num, ts_den = reaper.TimeMap_GetTimeSigAtTime(0, 0)
164
+ local sr = reaper.GetSetProjectInfo(0, "PROJECT_SRATE", 0, false)
165
+ local play_state = reaper.GetPlayState() -- 0=stopped, 1=playing, 2=paused, 4=recording
166
+ local cursor_pos = reaper.GetCursorPosition()
167
+
168
+ return {
169
+ name = proj_name or "",
170
+ path = proj_path or "",
171
+ trackCount = track_count,
172
+ tempo = tempo,
173
+ timeSignatureNumerator = ts_num,
174
+ timeSignatureDenominator = ts_den,
175
+ sampleRate = sr,
176
+ isPlaying = (play_state & 1) ~= 0,
177
+ isRecording = (play_state & 4) ~= 0,
178
+ cursorPosition = cursor_pos,
179
+ }
180
+ end
181
+
182
+ function handlers.list_tracks(params)
183
+ local tracks = {}
184
+ local count = reaper.CountTracks(0)
185
+ for i = 0, count - 1 do
186
+ local track = reaper.GetTrack(0, i)
187
+ local _, name = reaper.GetTrackName(track)
188
+ local vol = reaper.GetMediaTrackInfo_Value(track, "D_VOL")
189
+ local pan = reaper.GetMediaTrackInfo_Value(track, "D_PAN")
190
+ local mute = reaper.GetMediaTrackInfo_Value(track, "B_MUTE")
191
+ local solo = reaper.GetMediaTrackInfo_Value(track, "I_SOLO")
192
+ local fx_count = reaper.TrackFX_GetCount(track)
193
+ local depth = reaper.GetMediaTrackInfo_Value(track, "I_FOLDERDEPTH")
194
+ local parent = reaper.GetParentTrack(track)
195
+ local parent_idx = -1
196
+ if parent then
197
+ parent_idx = reaper.GetMediaTrackInfo_Value(parent, "IP_TRACKNUMBER") - 1
198
+ end
199
+ local color = reaper.GetMediaTrackInfo_Value(track, "I_CUSTOMCOLOR")
200
+
201
+ tracks[#tracks + 1] = {
202
+ index = i,
203
+ name = name,
204
+ volume = to_db(vol),
205
+ volumeRaw = vol,
206
+ pan = pan,
207
+ mute = mute ~= 0,
208
+ solo = solo ~= 0,
209
+ fxCount = fx_count,
210
+ receiveCount = reaper.GetTrackNumSends(track, -1),
211
+ sendCount = reaper.GetTrackNumSends(track, 0),
212
+ depth = depth,
213
+ parentIndex = parent_idx,
214
+ color = color,
215
+ }
216
+ end
217
+ return tracks
218
+ end
219
+
220
+ function handlers.get_track_properties(params)
221
+ local idx = params.trackIndex
222
+ if not idx then return nil, "trackIndex required" end
223
+
224
+ local track = reaper.GetTrack(0, idx)
225
+ if not track then return nil, "Track " .. idx .. " not found" end
226
+
227
+ local _, name = reaper.GetTrackName(track)
228
+ local vol = reaper.GetMediaTrackInfo_Value(track, "D_VOL")
229
+ local pan = reaper.GetMediaTrackInfo_Value(track, "D_PAN")
230
+ local mute = reaper.GetMediaTrackInfo_Value(track, "B_MUTE")
231
+ local solo = reaper.GetMediaTrackInfo_Value(track, "I_SOLO")
232
+ local fx_count = reaper.TrackFX_GetCount(track)
233
+ local depth = reaper.GetMediaTrackInfo_Value(track, "I_FOLDERDEPTH")
234
+ local parent = reaper.GetParentTrack(track)
235
+ local parent_idx = -1
236
+ if parent then
237
+ parent_idx = reaper.GetMediaTrackInfo_Value(parent, "IP_TRACKNUMBER") - 1
238
+ end
239
+ local color = reaper.GetMediaTrackInfo_Value(track, "I_CUSTOMCOLOR")
240
+
241
+ -- Build FX list
242
+ local fx_list = {}
243
+ for i = 0, fx_count - 1 do
244
+ local _, fx_name = reaper.TrackFX_GetFXName(track, i)
245
+ local enabled = reaper.TrackFX_GetEnabled(track, i)
246
+ local _, preset = reaper.TrackFX_GetPreset(track, i)
247
+ fx_list[#fx_list + 1] = {
248
+ index = i,
249
+ name = fx_name,
250
+ enabled = enabled,
251
+ preset = preset or "",
252
+ }
253
+ end
254
+
255
+ return {
256
+ index = idx,
257
+ name = name,
258
+ volume = to_db(vol),
259
+ volumeRaw = vol,
260
+ pan = pan,
261
+ mute = mute ~= 0,
262
+ solo = solo ~= 0,
263
+ fxCount = fx_count,
264
+ receiveCount = reaper.GetTrackNumSends(track, -1),
265
+ sendCount = reaper.GetTrackNumSends(track, 0),
266
+ depth = depth,
267
+ parentIndex = parent_idx,
268
+ color = color,
269
+ fxList = fx_list,
270
+ }
271
+ end
272
+
273
+ function handlers.set_track_property(params)
274
+ local idx = params.trackIndex
275
+ local prop = params.property
276
+ local value = params.value
277
+ if not idx or not prop or not value then
278
+ return nil, "trackIndex, property, and value required"
279
+ end
280
+
281
+ local track = reaper.GetTrack(0, idx)
282
+ if not track then return nil, "Track " .. idx .. " not found" end
283
+
284
+ if prop == "volume" then
285
+ reaper.SetMediaTrackInfo_Value(track, "D_VOL", from_db(value))
286
+ elseif prop == "pan" then
287
+ reaper.SetMediaTrackInfo_Value(track, "D_PAN", value)
288
+ elseif prop == "mute" then
289
+ reaper.SetMediaTrackInfo_Value(track, "B_MUTE", value)
290
+ elseif prop == "solo" then
291
+ reaper.SetMediaTrackInfo_Value(track, "I_SOLO", value)
292
+ else
293
+ return nil, "Unknown property: " .. tostring(prop)
294
+ end
295
+
296
+ return { success = true, trackIndex = idx, property = prop, value = value }
297
+ end
298
+
299
+ function handlers.add_fx(params)
300
+ local idx = params.trackIndex
301
+ local fx_name = params.fxName
302
+ if not idx or not fx_name then
303
+ return nil, "trackIndex and fxName required"
304
+ end
305
+
306
+ local track = reaper.GetTrack(0, idx)
307
+ if not track then return nil, "Track " .. idx .. " not found" end
308
+
309
+ local position = params.position or -1
310
+ local fx_idx = reaper.TrackFX_AddByName(track, fx_name, false, position)
311
+ if fx_idx < 0 then
312
+ return nil, "FX not found: " .. fx_name
313
+ end
314
+
315
+ local _, actual_name = reaper.TrackFX_GetFXName(track, fx_idx)
316
+ return {
317
+ fxIndex = fx_idx,
318
+ fxName = actual_name,
319
+ trackIndex = idx,
320
+ }
321
+ end
322
+
323
+ function handlers.remove_fx(params)
324
+ local idx = params.trackIndex
325
+ local fx_idx = params.fxIndex
326
+ if not idx or not fx_idx then
327
+ return nil, "trackIndex and fxIndex required"
328
+ end
329
+
330
+ local track = reaper.GetTrack(0, idx)
331
+ if not track then return nil, "Track " .. idx .. " not found" end
332
+
333
+ local ok = reaper.TrackFX_Delete(track, fx_idx)
334
+ if not ok then
335
+ return nil, "Failed to remove FX " .. fx_idx .. " from track " .. idx
336
+ end
337
+
338
+ return { success = true, trackIndex = idx, fxIndex = fx_idx }
339
+ end
340
+
341
+ function handlers.get_fx_parameters(params)
342
+ local idx = params.trackIndex
343
+ local fx_idx = params.fxIndex
344
+ if not idx or not fx_idx then
345
+ return nil, "trackIndex and fxIndex required"
346
+ end
347
+
348
+ local track = reaper.GetTrack(0, idx)
349
+ if not track then return nil, "Track " .. idx .. " not found" end
350
+
351
+ local param_count = reaper.TrackFX_GetNumParams(track, fx_idx)
352
+ local parameters = {}
353
+
354
+ for p = 0, param_count - 1 do
355
+ local _, pname = reaper.TrackFX_GetParamName(track, fx_idx, p)
356
+ local val, min_val, max_val = reaper.TrackFX_GetParam(track, fx_idx, p)
357
+ local _, formatted = reaper.TrackFX_GetFormattedParamValue(track, fx_idx, p)
358
+
359
+ parameters[#parameters + 1] = {
360
+ index = p,
361
+ name = pname,
362
+ value = val,
363
+ formattedValue = formatted or "",
364
+ minValue = min_val,
365
+ maxValue = max_val,
366
+ }
367
+ end
368
+
369
+ return {
370
+ trackIndex = idx,
371
+ fxIndex = fx_idx,
372
+ parameterCount = param_count,
373
+ parameters = parameters,
374
+ }
375
+ end
376
+
377
+ function handlers.set_fx_parameter(params)
378
+ local idx = params.trackIndex
379
+ local fx_idx = params.fxIndex
380
+ local param_idx = params.paramIndex
381
+ local value = params.value
382
+ if not idx or not fx_idx or not param_idx or not value then
383
+ return nil, "trackIndex, fxIndex, paramIndex, and value required"
384
+ end
385
+
386
+ local track = reaper.GetTrack(0, idx)
387
+ if not track then return nil, "Track " .. idx .. " not found" end
388
+
389
+ local ok = reaper.TrackFX_SetParam(track, fx_idx, param_idx, value)
390
+ if not ok then
391
+ return nil, "Failed to set param " .. param_idx .. " on FX " .. fx_idx
392
+ end
393
+
394
+ return { success = true, trackIndex = idx, fxIndex = fx_idx, paramIndex = param_idx, value = value }
395
+ end
396
+
397
+ function handlers.read_track_meters(params)
398
+ local idx = params.trackIndex
399
+ if not idx then return nil, "trackIndex required" end
400
+
401
+ local track = reaper.GetTrack(0, idx)
402
+ if not track then return nil, "Track " .. idx .. " not found" end
403
+
404
+ local peak_l = reaper.Track_GetPeakInfo(track, 0) -- channel 0 = left
405
+ local peak_r = reaper.Track_GetPeakInfo(track, 1) -- channel 1 = right
406
+
407
+ -- RMS is available via D_VOL peak hold with mode flags
408
+ -- For simplicity, use peak values (RMS requires more complex metering)
409
+ return {
410
+ trackIndex = idx,
411
+ peakL = to_db(peak_l),
412
+ peakR = to_db(peak_r),
413
+ rmsL = to_db(peak_l * 0.707), -- approximate RMS from peak
414
+ rmsR = to_db(peak_r * 0.707),
415
+ }
416
+ end
417
+
418
+ function handlers.read_track_spectrum(params)
419
+ local idx = params.trackIndex
420
+ if not idx then return nil, "trackIndex required" end
421
+
422
+ local track = reaper.GetTrack(0, idx)
423
+ if not track then return nil, "Track " .. idx .. " not found" end
424
+
425
+ -- Check if MCP analyzer JSFX is already on the track
426
+ local analyzer_idx = -1
427
+ local fx_count = reaper.TrackFX_GetCount(track)
428
+ for i = 0, fx_count - 1 do
429
+ local _, name = reaper.TrackFX_GetFXName(track, i)
430
+ if name and name:find(MCP_ANALYZER_FX_NAME) then
431
+ analyzer_idx = i
432
+ break
433
+ end
434
+ end
435
+
436
+ -- Auto-insert if not present
437
+ if analyzer_idx < 0 then
438
+ analyzer_idx = reaper.TrackFX_AddByName(track, MCP_ANALYZER_FX_NAME, false, -1)
439
+ if analyzer_idx < 0 then
440
+ return nil, "MCP Spectrum Analyzer JSFX not found. Run 'reaper-mcp setup' to install it."
441
+ end
442
+ end
443
+
444
+ -- Read spectrum data from gmem
445
+ -- JSFX writes: gmem[0] = bin_count, gmem[1] = peak_db, gmem[2] = rms_db, gmem[3..] = bins
446
+ reaper.gmem_attach("MCPAnalyzer")
447
+
448
+ local bin_count = reaper.gmem_read(0)
449
+ if bin_count <= 0 then
450
+ return nil, "Spectrum analyzer not producing data yet. Ensure audio is playing."
451
+ end
452
+
453
+ local peak_db = reaper.gmem_read(1)
454
+ local rms_db = reaper.gmem_read(2)
455
+ local sr = reaper.GetSetProjectInfo(0, "PROJECT_SRATE", 0, false)
456
+ local fft_size = params.fftSize or 4096
457
+
458
+ local bins = {}
459
+ for i = 0, bin_count - 1 do
460
+ bins[#bins + 1] = reaper.gmem_read(3 + i)
461
+ end
462
+
463
+ return {
464
+ trackIndex = idx,
465
+ fftSize = fft_size,
466
+ sampleRate = sr,
467
+ binCount = bin_count,
468
+ frequencyResolution = sr / fft_size,
469
+ peakDb = peak_db,
470
+ rmsDb = rms_db,
471
+ bins = bins,
472
+ }
473
+ end
474
+
475
+ -- =============================================================================
476
+ -- Transport handlers
477
+ -- =============================================================================
478
+
479
+ function handlers.play(params)
480
+ -- Action 1007 = Transport: Play
481
+ reaper.Main_OnCommand(1007, 0)
482
+ return { success = true }
483
+ end
484
+
485
+ function handlers.stop(params)
486
+ -- Action 1016 = Transport: Stop
487
+ reaper.Main_OnCommand(1016, 0)
488
+ return { success = true }
489
+ end
490
+
491
+ function handlers.record(params)
492
+ -- Action 1013 = Transport: Record
493
+ reaper.Main_OnCommand(1013, 0)
494
+ return { success = true }
495
+ end
496
+
497
+ function handlers.get_transport_state(params)
498
+ local play_state = reaper.GetPlayState() -- 0=stopped, 1=playing, 2=paused, 4=recording, 5=recording+playing
499
+ local cursor_pos = reaper.GetCursorPosition()
500
+ local play_pos = reaper.GetPlayPosition()
501
+ local tempo = reaper.Master_GetTempo()
502
+ local _, ts_num, ts_den = reaper.TimeMap_GetTimeSigAtTime(0, 0)
503
+
504
+ return {
505
+ playing = (play_state & 1) ~= 0,
506
+ recording = (play_state & 4) ~= 0,
507
+ paused = (play_state & 2) ~= 0,
508
+ cursorPosition = cursor_pos,
509
+ playPosition = play_pos,
510
+ tempo = tempo,
511
+ timeSignatureNumerator = ts_num,
512
+ timeSignatureDenominator = ts_den,
513
+ }
514
+ end
515
+
516
+ function handlers.set_cursor_position(params)
517
+ local pos = params.position
518
+ if not pos then return nil, "position required" end
519
+ if pos < 0 then pos = 0 end
520
+ reaper.SetEditCurPos(pos, true, false) -- moveview=true, seekplay=false
521
+ return { success = true, position = pos }
522
+ end
523
+
524
+ -- =============================================================================
525
+ -- Phase 1: Mix agent handlers
526
+ -- =============================================================================
527
+
528
+ -- Helper: detect FX type prefix from name
529
+ local function fx_type_from_name(name)
530
+ if name:match("^VST3:") then return "VST3"
531
+ elseif name:match("^VST:") then return "VST"
532
+ elseif name:match("^JS:") then return "JS"
533
+ elseif name:match("^AU:") then return "AU"
534
+ elseif name:match("^CLAP:") then return "CLAP"
535
+ else return "JS" end -- default for bare JSFX names
536
+ end
537
+
538
+ -- Enumerate all installed FX using SWS CF_EnumerateInstalledFX if available,
539
+ -- otherwise fall back to scanning open projects via TrackFX_AddByName probe.
540
+ -- The SWS approach is preferred and more complete.
541
+ function handlers.list_available_fx(params)
542
+ local category_filter = params.category
543
+ local fx_list = {}
544
+
545
+ -- Try SWS CF_EnumerateInstalledFX (requires SWS extension)
546
+ if reaper.CF_EnumerateInstalledFX then
547
+ local i = 0
548
+ while true do
549
+ local ok, name, ident = reaper.CF_EnumerateInstalledFX(i)
550
+ if not ok then break end
551
+ local fx_type = fx_type_from_name(name)
552
+ if not category_filter or fx_type:lower() == category_filter:lower() then
553
+ fx_list[#fx_list + 1] = {
554
+ name = name,
555
+ type = fx_type,
556
+ path = ident or "",
557
+ }
558
+ end
559
+ i = i + 1
560
+ end
561
+ return { fxList = fx_list, total = #fx_list, source = "sws" }
562
+ end
563
+
564
+ -- Fallback: parse reaper-fxfolders.ini for plugin names
565
+ local resource_path = reaper.GetResourcePath()
566
+ local ini_path = resource_path .. "/reaper-fxfolders.ini"
567
+ local content = read_file(ini_path)
568
+ if content then
569
+ for line in content:gmatch("[^\r\n]+") do
570
+ local name = line:match("^Item%d+=(.+)$")
571
+ if name and name ~= "" then
572
+ local fx_type = fx_type_from_name(name)
573
+ if not category_filter or fx_type:lower() == category_filter:lower() then
574
+ fx_list[#fx_list + 1] = { name = name, type = fx_type }
575
+ end
576
+ end
577
+ end
578
+ return { fxList = fx_list, total = #fx_list, source = "fxfolders_ini" }
579
+ end
580
+
581
+ return { fxList = fx_list, total = 0, source = "none",
582
+ warning = "SWS not installed and reaper-fxfolders.ini not found. Install SWS Extensions for full FX enumeration." }
583
+ end
584
+
585
+ function handlers.search_fx(params)
586
+ local query = params.query
587
+ if not query or query == "" then
588
+ return nil, "query required"
589
+ end
590
+
591
+ -- Get full list first (reuse list_available_fx logic)
592
+ local all_result = handlers.list_available_fx({})
593
+ local matches = {}
594
+ local q_lower = query:lower()
595
+
596
+ for _, fx in ipairs(all_result.fxList) do
597
+ if fx.name:lower():find(q_lower, 1, true) then
598
+ matches[#matches + 1] = fx
599
+ end
600
+ end
601
+
602
+ return { query = query, matches = matches, total = #matches }
603
+ end
604
+
605
+ function handlers.get_fx_preset_list(params)
606
+ local track_idx = params.trackIndex
607
+ local fx_idx = params.fxIndex
608
+ if track_idx == nil or fx_idx == nil then
609
+ return nil, "trackIndex and fxIndex required"
610
+ end
611
+
612
+ local track = reaper.GetTrack(0, track_idx)
613
+ if not track then return nil, "Track " .. track_idx .. " not found" end
614
+
615
+ local preset_count = reaper.TrackFX_GetPresetIndex(track, fx_idx)
616
+ local presets = {}
617
+
618
+ -- TrackFX_GetPresetIndex returns current index and total count
619
+ -- We iterate by cycling through presets
620
+ local _, total = reaper.TrackFX_GetPresetIndex(track, fx_idx)
621
+ if total and total > 0 then
622
+ for i = 0, total - 1 do
623
+ reaper.TrackFX_SetPresetByIndex(track, fx_idx, i)
624
+ local _, preset_name = reaper.TrackFX_GetPreset(track, fx_idx)
625
+ presets[#presets + 1] = { index = i, name = preset_name or ("Preset " .. i) }
626
+ end
627
+ -- Restore original preset
628
+ reaper.TrackFX_SetPresetByIndex(track, fx_idx, preset_count)
629
+ else
630
+ -- Plugin doesn't report preset count; return current preset only
631
+ local _, current_preset = reaper.TrackFX_GetPreset(track, fx_idx)
632
+ if current_preset and current_preset ~= "" then
633
+ presets[#presets + 1] = { index = 0, name = current_preset }
634
+ end
635
+ end
636
+
637
+ return { trackIndex = track_idx, fxIndex = fx_idx, presets = presets, total = #presets }
638
+ end
639
+
640
+ function handlers.set_fx_preset(params)
641
+ local track_idx = params.trackIndex
642
+ local fx_idx = params.fxIndex
643
+ local preset_name = params.presetName
644
+ if track_idx == nil or fx_idx == nil or not preset_name then
645
+ return nil, "trackIndex, fxIndex, and presetName required"
646
+ end
647
+
648
+ local track = reaper.GetTrack(0, track_idx)
649
+ if not track then return nil, "Track " .. track_idx .. " not found" end
650
+
651
+ -- TrackFX_SetPreset returns true on success
652
+ local ok = reaper.TrackFX_SetPreset(track, fx_idx, preset_name)
653
+ if not ok then
654
+ return nil, "Preset not found: " .. preset_name
655
+ end
656
+
657
+ local _, applied = reaper.TrackFX_GetPreset(track, fx_idx)
658
+ return { success = true, trackIndex = track_idx, fxIndex = fx_idx, presetName = applied or preset_name }
659
+ end
660
+
661
+ -- =============================================================================
662
+ -- Snapshot handlers
663
+ -- =============================================================================
664
+
665
+ local function get_snapshot_dir()
666
+ return bridge_dir .. "snapshots/"
667
+ end
668
+
669
+ local function ensure_snapshot_dir()
670
+ reaper.RecursiveCreateDirectory(get_snapshot_dir(), 0)
671
+ end
672
+
673
+ local function snapshot_path(name)
674
+ -- Sanitize name for filesystem
675
+ local safe = name:gsub("[^%w%-_%.%s]", "_"):gsub("%s+", "_")
676
+ return get_snapshot_dir() .. safe .. ".json"
677
+ end
678
+
679
+ local function capture_mixer_state()
680
+ local state = { tracks = {} }
681
+ local count = reaper.CountTracks(0)
682
+ for i = 0, count - 1 do
683
+ local track = reaper.GetTrack(0, i)
684
+ local _, name = reaper.GetTrackName(track)
685
+ local vol = reaper.GetMediaTrackInfo_Value(track, "D_VOL")
686
+ local pan = reaper.GetMediaTrackInfo_Value(track, "D_PAN")
687
+ local mute = reaper.GetMediaTrackInfo_Value(track, "B_MUTE")
688
+ local solo = reaper.GetMediaTrackInfo_Value(track, "I_SOLO")
689
+
690
+ -- Capture FX bypass states
691
+ local fx_count = reaper.TrackFX_GetCount(track)
692
+ local fx_states = {}
693
+ for j = 0, fx_count - 1 do
694
+ fx_states[#fx_states + 1] = reaper.TrackFX_GetEnabled(track, j)
695
+ end
696
+
697
+ state.tracks[#state.tracks + 1] = {
698
+ index = i,
699
+ name = name,
700
+ volume = vol,
701
+ pan = pan,
702
+ mute = mute ~= 0,
703
+ solo = solo ~= 0,
704
+ fxEnabled = fx_states,
705
+ }
706
+ end
707
+ return state
708
+ end
709
+
710
+ function handlers.snapshot_save(params)
711
+ local name = params.name
712
+ if not name or name == "" then
713
+ return nil, "name required"
714
+ end
715
+
716
+ ensure_snapshot_dir()
717
+
718
+ local timestamp = os.time() * 1000
719
+ local snapshot = {
720
+ name = name,
721
+ description = params.description or "",
722
+ timestamp = timestamp,
723
+ mixerState = capture_mixer_state(),
724
+ }
725
+
726
+ local path = snapshot_path(name)
727
+ local ok = write_file(path, json_encode(snapshot))
728
+ if not ok then
729
+ return nil, "Failed to write snapshot file: " .. path
730
+ end
731
+
732
+ return { success = true, name = name, timestamp = timestamp, path = path }
733
+ end
734
+
735
+ function handlers.snapshot_restore(params)
736
+ local name = params.name
737
+ if not name or name == "" then
738
+ return nil, "name required"
739
+ end
740
+
741
+ local path = snapshot_path(name)
742
+ local content = read_file(path)
743
+ if not content then
744
+ return nil, "Snapshot not found: " .. name
745
+ end
746
+
747
+ local snapshot = json_decode(content)
748
+ if not snapshot or not snapshot.mixerState then
749
+ return nil, "Invalid snapshot file for: " .. name
750
+ end
751
+
752
+ -- Restore each track state
753
+ local restored = 0
754
+ local state = snapshot.mixerState
755
+ if state.tracks then
756
+ for _, track_state in ipairs(state.tracks) do
757
+ local track = reaper.GetTrack(0, track_state.index)
758
+ if track then
759
+ reaper.SetMediaTrackInfo_Value(track, "D_VOL", track_state.volume)
760
+ reaper.SetMediaTrackInfo_Value(track, "D_PAN", track_state.pan)
761
+ reaper.SetMediaTrackInfo_Value(track, "B_MUTE", track_state.mute and 1 or 0)
762
+ reaper.SetMediaTrackInfo_Value(track, "I_SOLO", track_state.solo and 1 or 0)
763
+
764
+ -- Restore FX bypass states
765
+ if track_state.fxEnabled then
766
+ for j, enabled in ipairs(track_state.fxEnabled) do
767
+ local fx_idx = j - 1 -- convert to 0-based
768
+ if fx_idx < reaper.TrackFX_GetCount(track) then
769
+ reaper.TrackFX_SetEnabled(track, fx_idx, enabled)
770
+ end
771
+ end
772
+ end
773
+ restored = restored + 1
774
+ end
775
+ end
776
+ end
777
+
778
+ reaper.TrackList_AdjustWindows(false)
779
+ reaper.UpdateArrange()
780
+
781
+ return {
782
+ success = true,
783
+ name = name,
784
+ timestamp = snapshot.timestamp,
785
+ tracksRestored = restored,
786
+ }
787
+ end
788
+
789
+ function handlers.snapshot_list(params)
790
+ ensure_snapshot_dir()
791
+ local snap_dir = get_snapshot_dir()
792
+ local snapshots = {}
793
+
794
+ local i = 0
795
+ while true do
796
+ local fn = reaper.EnumerateFiles(snap_dir, i)
797
+ if not fn then break end
798
+ if fn:match("%.json$") then
799
+ local content = read_file(snap_dir .. fn)
800
+ if content then
801
+ local snap = json_decode(content)
802
+ if snap and snap.name then
803
+ snapshots[#snapshots + 1] = {
804
+ name = snap.name,
805
+ description = snap.description or "",
806
+ timestamp = snap.timestamp or 0,
807
+ }
808
+ end
809
+ end
810
+ end
811
+ i = i + 1
812
+ end
813
+
814
+ -- Sort by timestamp descending (newest first)
815
+ table.sort(snapshots, function(a, b) return a.timestamp > b.timestamp end)
816
+
817
+ return { snapshots = snapshots, total = #snapshots }
818
+ end
819
+
820
+ -- =============================================================================
821
+ -- Routing handler
822
+ -- =============================================================================
823
+
824
+ function handlers.get_track_routing(params)
825
+ local track_idx = params.trackIndex
826
+ if track_idx == nil then
827
+ return nil, "trackIndex required"
828
+ end
829
+
830
+ local track = reaper.GetTrack(0, track_idx)
831
+ if not track then return nil, "Track " .. track_idx .. " not found" end
832
+
833
+ -- Sends (category 0)
834
+ local send_count = reaper.GetTrackNumSends(track, 0)
835
+ local sends = {}
836
+ for i = 0, send_count - 1 do
837
+ local dest_track = reaper.GetTrackSendInfo_Value(track, 0, i, "P_DESTTRACK")
838
+ local dest_idx = -1
839
+ local dest_name = ""
840
+ if dest_track then
841
+ dest_idx = reaper.GetMediaTrackInfo_Value(dest_track, "IP_TRACKNUMBER") - 1
842
+ local _, dname = reaper.GetTrackName(dest_track)
843
+ dest_name = dname or ""
844
+ end
845
+ local send_vol = reaper.GetTrackSendInfo_Value(track, 0, i, "D_VOL")
846
+ local send_pan = reaper.GetTrackSendInfo_Value(track, 0, i, "D_PAN")
847
+ local send_mute = reaper.GetTrackSendInfo_Value(track, 0, i, "B_MUTE")
848
+ sends[#sends + 1] = {
849
+ destTrackIndex = dest_idx,
850
+ destTrackName = dest_name,
851
+ volume = to_db(send_vol),
852
+ pan = send_pan,
853
+ muted = send_mute ~= 0,
854
+ }
855
+ end
856
+
857
+ -- Receives (category -1)
858
+ local recv_count = reaper.GetTrackNumSends(track, -1)
859
+ local receives = {}
860
+ for i = 0, recv_count - 1 do
861
+ local src_track = reaper.GetTrackSendInfo_Value(track, -1, i, "P_SRCTRACK")
862
+ local src_idx = -1
863
+ local src_name = ""
864
+ if src_track then
865
+ src_idx = reaper.GetMediaTrackInfo_Value(src_track, "IP_TRACKNUMBER") - 1
866
+ local _, sname = reaper.GetTrackName(src_track)
867
+ src_name = sname or ""
868
+ end
869
+ local recv_vol = reaper.GetTrackSendInfo_Value(track, -1, i, "D_VOL")
870
+ local recv_pan = reaper.GetTrackSendInfo_Value(track, -1, i, "D_PAN")
871
+ local recv_mute = reaper.GetTrackSendInfo_Value(track, -1, i, "B_MUTE")
872
+ receives[#receives + 1] = {
873
+ srcTrackIndex = src_idx,
874
+ srcTrackName = src_name,
875
+ volume = to_db(recv_vol),
876
+ pan = recv_pan,
877
+ muted = recv_mute ~= 0,
878
+ }
879
+ end
880
+
881
+ local parent = reaper.GetParentTrack(track)
882
+ local parent_idx = -1
883
+ if parent then
884
+ parent_idx = reaper.GetMediaTrackInfo_Value(parent, "IP_TRACKNUMBER") - 1
885
+ end
886
+
887
+ local folder_depth = reaper.GetMediaTrackInfo_Value(track, "I_FOLDERDEPTH")
888
+
889
+ return {
890
+ trackIndex = track_idx,
891
+ sends = sends,
892
+ receives = receives,
893
+ parentTrackIndex = parent_idx,
894
+ isFolder = folder_depth > 0,
895
+ folderDepth = folder_depth,
896
+ }
897
+ end
898
+
899
+ -- =============================================================================
900
+ -- Phase 4: Custom JSFX analyzer handlers
901
+ -- =============================================================================
902
+
903
+ local MCP_LUFS_METER_FX_NAME = "mcp_lufs_meter"
904
+ local MCP_CORRELATION_METER_FX_NAME = "mcp_correlation_meter"
905
+ local MCP_CREST_FACTOR_FX_NAME = "mcp_crest_factor"
906
+
907
+ -- Helper: find or auto-insert a named JSFX on a track.
908
+ -- Returns the FX index (0-based) on success, or nil + error message on failure.
909
+ local function ensure_jsfx_on_track(track, fx_name)
910
+ local fx_count = reaper.TrackFX_GetCount(track)
911
+ for i = 0, fx_count - 1 do
912
+ local _, name = reaper.TrackFX_GetFXName(track, i)
913
+ if name and name:find(fx_name, 1, true) then
914
+ return i
915
+ end
916
+ end
917
+ -- Auto-insert
918
+ local idx = reaper.TrackFX_AddByName(track, fx_name, false, -1)
919
+ if idx < 0 then
920
+ return nil, fx_name .. " JSFX not found. Run 'reaper-mcp setup' to install it."
921
+ end
922
+ return idx
923
+ end
924
+
925
+ function handlers.read_track_lufs(params)
926
+ local idx = params.trackIndex
927
+ if idx == nil then return nil, "trackIndex required" end
928
+
929
+ local track = reaper.GetTrack(0, idx)
930
+ if not track then return nil, "Track " .. idx .. " not found" end
931
+
932
+ local fx_idx, err = ensure_jsfx_on_track(track, MCP_LUFS_METER_FX_NAME)
933
+ if not fx_idx then return nil, err end
934
+
935
+ -- Attach to the LUFS meter gmem namespace and read 6 values
936
+ reaper.gmem_attach("MCPLufsMeter")
937
+
938
+ local integrated = reaper.gmem_read(0)
939
+ local short_term = reaper.gmem_read(1)
940
+ local momentary = reaper.gmem_read(2)
941
+ local true_peak_l = reaper.gmem_read(3)
942
+ local true_peak_r = reaper.gmem_read(4)
943
+ local duration = reaper.gmem_read(5)
944
+
945
+ -- A duration of 0 means no audio has been processed yet
946
+ if duration <= 0 then
947
+ return nil, "LUFS meter not producing data yet. Ensure audio is playing."
948
+ end
949
+
950
+ return {
951
+ trackIndex = idx,
952
+ integrated = integrated,
953
+ shortTerm = short_term,
954
+ momentary = momentary,
955
+ truePeakL = true_peak_l,
956
+ truePeakR = true_peak_r,
957
+ duration = duration,
958
+ }
959
+ end
960
+
961
+ function handlers.read_track_correlation(params)
962
+ local idx = params.trackIndex
963
+ if idx == nil then return nil, "trackIndex required" end
964
+
965
+ local track = reaper.GetTrack(0, idx)
966
+ if not track then return nil, "Track " .. idx .. " not found" end
967
+
968
+ local fx_idx, err = ensure_jsfx_on_track(track, MCP_CORRELATION_METER_FX_NAME)
969
+ if not fx_idx then return nil, err end
970
+
971
+ reaper.gmem_attach("MCPCorrelationMeter")
972
+
973
+ local correlation = reaper.gmem_read(0)
974
+ local stereo_width = reaper.gmem_read(1)
975
+ local mid_level = reaper.gmem_read(2)
976
+ local side_level = reaper.gmem_read(3)
977
+
978
+ return {
979
+ trackIndex = idx,
980
+ correlation = correlation,
981
+ stereoWidth = stereo_width,
982
+ midLevel = mid_level,
983
+ sideLevel = side_level,
984
+ }
985
+ end
986
+
987
+ function handlers.read_track_crest(params)
988
+ local idx = params.trackIndex
989
+ if idx == nil then return nil, "trackIndex required" end
990
+
991
+ local track = reaper.GetTrack(0, idx)
992
+ if not track then return nil, "Track " .. idx .. " not found" end
993
+
994
+ local fx_idx, err = ensure_jsfx_on_track(track, MCP_CREST_FACTOR_FX_NAME)
995
+ if not fx_idx then return nil, err end
996
+
997
+ reaper.gmem_attach("MCPCrestFactor")
998
+
999
+ local crest_factor = reaper.gmem_read(0)
1000
+ local peak_level = reaper.gmem_read(1)
1001
+ local rms_level = reaper.gmem_read(2)
1002
+
1003
+ return {
1004
+ trackIndex = idx,
1005
+ crestFactor = crest_factor,
1006
+ peakLevel = peak_level,
1007
+ rmsLevel = rms_level,
1008
+ }
1009
+ end
1010
+
1011
+ -- =============================================================================
1012
+ -- Command dispatcher
1013
+ -- =============================================================================
1014
+
1015
+ local function process_command(filename)
1016
+ local path = bridge_dir .. filename
1017
+ local content = read_file(path)
1018
+ if not content then return end
1019
+
1020
+ local cmd = json_decode(content)
1021
+ if not cmd or not cmd.id or not cmd.type then
1022
+ -- Invalid command, remove file
1023
+ os.remove(path)
1024
+ return
1025
+ end
1026
+
1027
+ -- Dispatch to handler
1028
+ local handler = handlers[cmd.type]
1029
+ local response = {}
1030
+
1031
+ if handler then
1032
+ local data, err = handler(cmd.params or {})
1033
+ if err then
1034
+ response = { id = cmd.id, success = false, error = err, timestamp = os.time() * 1000 }
1035
+ else
1036
+ response = { id = cmd.id, success = true, data = data, timestamp = os.time() * 1000 }
1037
+ end
1038
+ else
1039
+ response = {
1040
+ id = cmd.id,
1041
+ success = false,
1042
+ error = "Unknown command type: " .. tostring(cmd.type),
1043
+ timestamp = os.time() * 1000,
1044
+ }
1045
+ end
1046
+
1047
+ -- Write response
1048
+ local response_path = bridge_dir .. "response_" .. cmd.id .. ".json"
1049
+ write_file(response_path, json_encode(response))
1050
+
1051
+ -- Remove command file
1052
+ os.remove(path)
1053
+ end
1054
+
1055
+ -- =============================================================================
1056
+ -- Heartbeat
1057
+ -- =============================================================================
1058
+
1059
+ local function write_heartbeat()
1060
+ local heartbeat = {
1061
+ timestamp = os.time() * 1000,
1062
+ reaperVersion = reaper.GetAppVersion(),
1063
+ projectName = select(2, reaper.EnumProjects(-1)) or "",
1064
+ }
1065
+ write_file(bridge_dir .. "heartbeat.json", json_encode(heartbeat))
1066
+ end
1067
+
1068
+ -- =============================================================================
1069
+ -- Main defer loop
1070
+ -- =============================================================================
1071
+
1072
+ local function main_loop()
1073
+ local now = reaper.time_precise()
1074
+
1075
+ -- Poll for commands at interval
1076
+ if now - last_poll >= POLL_INTERVAL then
1077
+ last_poll = now
1078
+ local files = list_files(bridge_dir, "command_")
1079
+ for _, filename in ipairs(files) do
1080
+ process_command(filename)
1081
+ end
1082
+ end
1083
+
1084
+ -- Write heartbeat at interval
1085
+ if now - last_heartbeat >= HEARTBEAT_INTERVAL then
1086
+ last_heartbeat = now
1087
+ write_heartbeat()
1088
+ end
1089
+
1090
+ reaper.defer(main_loop)
1091
+ end
1092
+
1093
+ -- =============================================================================
1094
+ -- Startup
1095
+ -- =============================================================================
1096
+
1097
+ reaper.ShowConsoleMsg("MCP Bridge: Started\n")
1098
+ reaper.ShowConsoleMsg("MCP Bridge: Bridge directory: " .. bridge_dir .. "\n")
1099
+ reaper.ShowConsoleMsg("MCP Bridge: Polling every " .. (POLL_INTERVAL * 1000) .. "ms\n")
1100
+
1101
+ -- Write initial heartbeat
1102
+ write_heartbeat()
1103
+
1104
+ -- Start the loop
1105
+ main_loop()