@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.
- package/README.md +281 -0
- package/claude-rules/architecture.md +39 -0
- package/claude-rules/development.md +54 -0
- package/claude-rules/lua-bridge.md +50 -0
- package/claude-rules/testing.md +42 -0
- package/claude-skills/learn-plugin.md +123 -0
- package/knowledge/genres/_template.md +109 -0
- package/knowledge/genres/electronic.md +112 -0
- package/knowledge/genres/hip-hop.md +111 -0
- package/knowledge/genres/metal.md +136 -0
- package/knowledge/genres/orchestral.md +132 -0
- package/knowledge/genres/pop.md +108 -0
- package/knowledge/genres/rock.md +117 -0
- package/knowledge/plugins/_template.md +82 -0
- package/knowledge/plugins/fabfilter/pro-c-2.md +117 -0
- package/knowledge/plugins/fabfilter/pro-l-2.md +95 -0
- package/knowledge/plugins/fabfilter/pro-q-3.md +112 -0
- package/knowledge/plugins/neural-dsp/helix-native.md +104 -0
- package/knowledge/plugins/stock-reaper/js-1175-compressor.md +94 -0
- package/knowledge/plugins/stock-reaper/rea-comp.md +100 -0
- package/knowledge/plugins/stock-reaper/rea-delay.md +95 -0
- package/knowledge/plugins/stock-reaper/rea-eq.md +103 -0
- package/knowledge/plugins/stock-reaper/rea-gate.md +99 -0
- package/knowledge/plugins/stock-reaper/rea-limit.md +75 -0
- package/knowledge/plugins/stock-reaper/rea-verb.md +76 -0
- package/knowledge/reference/common-mistakes.md +307 -0
- package/knowledge/reference/compression.md +176 -0
- package/knowledge/reference/frequencies.md +154 -0
- package/knowledge/reference/metering.md +166 -0
- package/knowledge/workflows/drum-bus.md +211 -0
- package/knowledge/workflows/gain-staging.md +165 -0
- package/knowledge/workflows/low-end.md +261 -0
- package/knowledge/workflows/master-bus.md +204 -0
- package/knowledge/workflows/vocal-chain.md +246 -0
- package/main.js +755 -0
- package/package.json +44 -0
- package/reaper/install.sh +50 -0
- package/reaper/mcp_analyzer.jsfx +167 -0
- package/reaper/mcp_bridge.lua +1105 -0
- package/reaper/mcp_correlation_meter.jsfx +148 -0
- package/reaper/mcp_crest_factor.jsfx +108 -0
- 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()
|