@mthines/reaper-mcp 0.6.0 → 0.8.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.
@@ -11,7 +11,7 @@
11
11
 
12
12
  local POLL_INTERVAL = 0.030 -- 30ms between polls
13
13
  local HEARTBEAT_INTERVAL = 1.0 -- write heartbeat every 1s
14
- local MCP_ANALYZER_FX_NAME = "mcp_analyzer" -- JSFX analyzer name
14
+ local MCP_ANALYZER_FX_NAME = "reaper-mcp/mcp_analyzer" -- JSFX analyzer name
15
15
 
16
16
  -- Determine bridge directory (REAPER resource path / Scripts / mcp_bridge_data)
17
17
  local bridge_dir = reaper.GetResourcePath() .. "/Scripts/mcp_bridge_data/"
@@ -26,57 +26,140 @@ local last_heartbeat = 0
26
26
  -- JSON Parser (minimal, sufficient for our command format)
27
27
  -- =============================================================================
28
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
29
+ -- Helper: extract a JSON string value starting at pos (after the opening quote).
30
+ -- Handles escaped quotes (\") and other escape sequences.
31
+ local function extract_json_string(str, pos)
32
+ local result = {}
33
+ local i = pos
34
+ while i <= #str do
35
+ local ch = str:sub(i, i)
36
+ if ch == '\\' then
37
+ local next_ch = str:sub(i + 1, i + 1)
38
+ if next_ch == '"' then
39
+ result[#result + 1] = '"'
40
+ elseif next_ch == '\\' then
41
+ result[#result + 1] = '\\'
42
+ elseif next_ch == 'n' then
43
+ result[#result + 1] = '\n'
44
+ elseif next_ch == 't' then
45
+ result[#result + 1] = '\t'
46
+ elseif next_ch == '/' then
47
+ result[#result + 1] = '/'
48
+ else
49
+ result[#result + 1] = next_ch
50
+ end
51
+ i = i + 2
52
+ elseif ch == '"' then
53
+ return table.concat(result), i + 1 -- return string and position after closing quote
54
+ else
55
+ result[#result + 1] = ch
56
+ i = i + 1
57
+ end
34
58
  end
59
+ return nil, i -- unterminated string
60
+ end
35
61
 
36
- -- Fallback: use Lua pattern matching for our simple JSON format
37
- -- This handles the flat command objects we receive
62
+ -- Parse a flat JSON object with string/number values in params.
63
+ -- Handles escaped quotes in string values (e.g. JSON arrays passed as strings).
64
+ local function parse_flat_object(str)
38
65
  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
66
+ local i = 1
67
+ while i <= #str do
68
+ -- Find a key
69
+ local key_start = str:find('"', i)
70
+ if not key_start then break end
71
+ local key_end = str:find('"', key_start + 1)
72
+ if not key_end then break end
73
+ local key = str:sub(key_start + 1, key_end - 1)
74
+
75
+ -- Find the colon
76
+ local colon = str:find(':', key_end + 1)
77
+ if not colon then break end
78
+
79
+ -- Skip whitespace after colon
80
+ local val_start = str:match('^%s*()', colon + 1)
81
+
82
+ local ch = str:sub(val_start, val_start)
83
+ if ch == '"' then
84
+ -- String value (handles escaped quotes)
85
+ local val, next_pos = extract_json_string(str, val_start + 1)
86
+ if val then
87
+ obj[key] = val
88
+ i = next_pos
89
+ else
90
+ i = val_start + 1
91
+ end
92
+ elseif ch == '{' then
93
+ -- Nested object — extract with balanced braces
94
+ local nested = str:match('%b{}', val_start)
95
+ if nested then
96
+ obj[key] = parse_flat_object(nested)
97
+ i = val_start + #nested
98
+ else
99
+ i = val_start + 1
100
+ end
101
+ elseif ch == '[' then
102
+ -- Array — extract with balanced brackets
103
+ local arr = str:match('%b[]', val_start)
104
+ if arr then
105
+ obj[key] = arr -- keep as string for json_decode_array to parse later
106
+ i = val_start + #arr
107
+ else
108
+ i = val_start + 1
109
+ end
110
+ elseif ch:match('[%d%-]') then
111
+ -- Number
112
+ local num_str = str:match('-?%d+%.?%d*', val_start)
113
+ if num_str then
114
+ obj[key] = tonumber(num_str)
115
+ i = val_start + #num_str
116
+ else
117
+ i = val_start + 1
118
+ end
119
+ elseif str:sub(val_start, val_start + 3) == 'true' then
120
+ obj[key] = true
121
+ i = val_start + 4
122
+ elseif str:sub(val_start, val_start + 4) == 'false' then
123
+ obj[key] = false
124
+ i = val_start + 5
125
+ elseif str:sub(val_start, val_start + 3) == 'null' then
126
+ i = val_start + 4
127
+ else
128
+ i = val_start + 1
56
129
  end
57
130
  end
58
131
  return obj
59
132
  end
60
133
 
61
- -- Fallback JSON array-of-objects parser for when CF_Json_Parse is unavailable.
62
- -- Handles: [{"key":val,...}, {"key":val,...}, ...]
63
- local function json_decode_array(str)
134
+ local function json_decode(str)
135
+ -- Use REAPER 7+ built-in JSON if available, otherwise basic parser
64
136
  if reaper.CF_Json_Parse then
65
137
  local ok, val = reaper.CF_Json_Parse(str)
66
138
  if ok then return val end
67
139
  end
68
140
 
141
+ -- Fallback: parse the command JSON using our robust parser
142
+ -- that handles escaped quotes in string values (needed for batch commands)
143
+ return parse_flat_object(str)
144
+ end
145
+
146
+ -- Parse a JSON array of objects. Accepts either a Lua table (already parsed by
147
+ -- CF_Json_Parse) or a JSON string. Falls back to extracting each {...} and
148
+ -- parsing with parse_flat_object.
149
+ local function json_decode_array(input)
150
+ -- If already a table (e.g. CF_Json_Parse decoded it), return directly
151
+ if type(input) == "table" then return input end
152
+ if type(input) ~= "string" then return nil end
153
+
154
+ if reaper.CF_Json_Parse then
155
+ local ok, val = reaper.CF_Json_Parse(input)
156
+ if ok then return val end
157
+ end
158
+
69
159
  -- Fallback: extract each {...} from the array and parse individually
70
160
  local arr = {}
71
- for obj_str in str:gmatch("%b{}") do
72
- local obj = {}
73
- for k, v in obj_str:gmatch('"([^"]+)"%s*:%s*"([^"]*)"') do
74
- obj[k] = v
75
- end
76
- for k, v in obj_str:gmatch('"([^"]+)"%s*:%s*(-?%d+%.?%d*)') do
77
- if not obj[k] then obj[k] = tonumber(v) end
78
- end
79
- arr[#arr + 1] = obj
161
+ for obj_str in input:gmatch("%b{}") do
162
+ arr[#arr + 1] = parse_flat_object(obj_str)
80
163
  end
81
164
  if #arr > 0 then return arr end
82
165
  return nil
@@ -924,9 +1007,9 @@ end
924
1007
  -- Phase 4: Custom JSFX analyzer handlers
925
1008
  -- =============================================================================
926
1009
 
927
- local MCP_LUFS_METER_FX_NAME = "mcp_lufs_meter"
928
- local MCP_CORRELATION_METER_FX_NAME = "mcp_correlation_meter"
929
- local MCP_CREST_FACTOR_FX_NAME = "mcp_crest_factor"
1010
+ local MCP_LUFS_METER_FX_NAME = "reaper-mcp/mcp_lufs_meter"
1011
+ local MCP_CORRELATION_METER_FX_NAME = "reaper-mcp/mcp_correlation_meter"
1012
+ local MCP_CREST_FACTOR_FX_NAME = "reaper-mcp/mcp_crest_factor"
930
1013
 
931
1014
  -- Helper: find or auto-insert a named JSFX on a track.
932
1015
  -- Returns the FX index (0-based) on success, or nil + error message on failure.
@@ -1176,9 +1259,17 @@ function handlers.get_midi_notes(params)
1176
1259
 
1177
1260
  local _, note_cnt, _, _ = reaper.MIDI_CountEvts(take)
1178
1261
  local to_beats = ppq_to_beats(take, item)
1179
- local notes = {}
1180
1262
 
1181
- for i = 0, note_cnt - 1 do
1263
+ local offset = params.offset or 0
1264
+ local limit = params.limit
1265
+ local start_idx = math.min(offset, note_cnt)
1266
+ local end_idx = note_cnt - 1
1267
+ if limit then
1268
+ end_idx = math.min(start_idx + limit - 1, note_cnt - 1)
1269
+ end
1270
+
1271
+ local notes = {}
1272
+ for i = start_idx, end_idx do
1182
1273
  local _, sel, muted, start_ppq, end_ppq, chan, pitch, vel = reaper.MIDI_GetNote(take, i)
1183
1274
  local start_beats = to_beats(start_ppq)
1184
1275
  local end_beats = to_beats(end_ppq)
@@ -1194,7 +1285,114 @@ function handlers.get_midi_notes(params)
1194
1285
  }
1195
1286
  end
1196
1287
 
1197
- return { trackIndex = params.trackIndex, itemIndex = params.itemIndex, notes = notes, total = #notes }
1288
+ return {
1289
+ trackIndex = params.trackIndex,
1290
+ itemIndex = params.itemIndex,
1291
+ notes = notes,
1292
+ returned = #notes,
1293
+ total = note_cnt,
1294
+ offset = start_idx,
1295
+ hasMore = end_idx < note_cnt - 1,
1296
+ }
1297
+ end
1298
+
1299
+ function handlers.analyze_midi(params)
1300
+ local track, item, take, err = get_midi_take(params)
1301
+ if err then return nil, err end
1302
+
1303
+ local _, note_cnt, cc_cnt, _ = reaper.MIDI_CountEvts(take)
1304
+ local to_beats = ppq_to_beats(take, item)
1305
+
1306
+ -- Per-pitch accumulators
1307
+ local pitch_data = {} -- pitch -> { count, sum_vel, sum_sq, min_vel, max_vel, last_vel, consec, max_consec, sequences }
1308
+ -- Velocity histogram: 13 buckets (0-9, 10-19, ..., 120-127)
1309
+ local vel_histogram = {}
1310
+ for i = 1, 13 do vel_histogram[i] = 0 end
1311
+
1312
+ local max_end_beat = 0
1313
+
1314
+ for i = 0, note_cnt - 1 do
1315
+ local _, _, _, start_ppq, end_ppq, _, pitch, vel = reaper.MIDI_GetNote(take, i)
1316
+
1317
+ -- Track duration
1318
+ local end_beat = to_beats(end_ppq)
1319
+ if end_beat > max_end_beat then max_end_beat = end_beat end
1320
+
1321
+ -- Velocity histogram bucket
1322
+ local bucket = math.min(math.floor(vel / 10) + 1, 13)
1323
+ vel_histogram[bucket] = vel_histogram[bucket] + 1
1324
+
1325
+ -- Per-pitch stats
1326
+ if not pitch_data[pitch] then
1327
+ pitch_data[pitch] = {
1328
+ count = 0, sum_vel = 0, sum_sq = 0,
1329
+ min_vel = 127, max_vel = 0,
1330
+ last_vel = -1, consec = 1, max_consec = 1, sequences = 0,
1331
+ }
1332
+ end
1333
+ local pd = pitch_data[pitch]
1334
+ pd.count = pd.count + 1
1335
+ pd.sum_vel = pd.sum_vel + vel
1336
+ pd.sum_sq = pd.sum_sq + vel * vel
1337
+ if vel < pd.min_vel then pd.min_vel = vel end
1338
+ if vel > pd.max_vel then pd.max_vel = vel end
1339
+
1340
+ -- Machine gun detection: consecutive identical velocities
1341
+ if vel == pd.last_vel then
1342
+ pd.consec = pd.consec + 1
1343
+ if pd.consec > pd.max_consec then pd.max_consec = pd.consec end
1344
+ else
1345
+ if pd.consec >= 3 then pd.sequences = pd.sequences + 1 end
1346
+ pd.consec = 1
1347
+ end
1348
+ pd.last_vel = vel
1349
+ end
1350
+
1351
+ -- Build pitch stats array
1352
+ local pitch_stats = {}
1353
+ local machine_gun = {}
1354
+ local sorted_pitches = {}
1355
+ for p, _ in pairs(pitch_data) do sorted_pitches[#sorted_pitches + 1] = p end
1356
+ table.sort(sorted_pitches)
1357
+
1358
+ for _, pitch in ipairs(sorted_pitches) do
1359
+ local pd = pitch_data[pitch]
1360
+ -- Close final run
1361
+ if pd.consec >= 3 then pd.sequences = pd.sequences + 1 end
1362
+
1363
+ local avg = pd.sum_vel / pd.count
1364
+ local variance = (pd.sum_sq / pd.count) - (avg * avg)
1365
+ local std_dev = math.sqrt(math.max(0, variance))
1366
+
1367
+ pitch_stats[#pitch_stats + 1] = {
1368
+ pitch = pitch,
1369
+ count = pd.count,
1370
+ minVelocity = pd.min_vel,
1371
+ maxVelocity = pd.max_vel,
1372
+ avgVelocity = math.floor(avg * 10 + 0.5) / 10, -- round to 1 decimal
1373
+ stdDev = math.floor(std_dev * 10 + 0.5) / 10,
1374
+ maxConsecutiveSameVelocity = pd.max_consec,
1375
+ }
1376
+
1377
+ if pd.max_consec >= 3 then
1378
+ machine_gun[#machine_gun + 1] = {
1379
+ pitch = pitch,
1380
+ maxConsecutive = pd.max_consec,
1381
+ sequences = pd.sequences,
1382
+ }
1383
+ end
1384
+ end
1385
+
1386
+ return {
1387
+ trackIndex = params.trackIndex,
1388
+ itemIndex = params.itemIndex,
1389
+ totalNotes = note_cnt,
1390
+ totalCC = cc_cnt,
1391
+ durationBeats = math.floor(max_end_beat * 100 + 0.5) / 100,
1392
+ pitchStats = pitch_stats,
1393
+ velocityHistogram = vel_histogram,
1394
+ machineGunWarnings = machine_gun,
1395
+ }
1198
1396
  end
1199
1397
 
1200
1398
  function handlers.insert_midi_note(params)
@@ -1234,13 +1432,13 @@ function handlers.insert_midi_notes(params)
1234
1432
  local track, item, take, err = get_midi_take(params)
1235
1433
  if err then return nil, err end
1236
1434
 
1237
- local notes_str = params.notes
1238
- if not notes_str or notes_str == "" then return nil, "notes JSON string required" end
1435
+ local notes_input = params.notes
1436
+ if not notes_input or notes_input == "" then return nil, "notes array required" end
1239
1437
 
1240
- -- Parse notes JSON array (uses dedicated array parser with fallback)
1241
- local notes_list = json_decode_array(notes_str)
1438
+ -- Parse notes array (accepts native table from CF_Json_Parse or JSON string fallback)
1439
+ local notes_list = json_decode_array(notes_input)
1242
1440
  if not notes_list or #notes_list == 0 then
1243
- return nil, "Failed to parse notes JSON array. Expected: [{\"pitch\":60,\"velocity\":100,\"startPosition\":0,\"duration\":1}, ...]"
1441
+ return nil, "Failed to parse notes array. Expected array of {pitch, velocity, startPosition, duration} objects."
1244
1442
  end
1245
1443
 
1246
1444
  reaper.Undo_BeginBlock()
@@ -1321,6 +1519,65 @@ function handlers.edit_midi_note(params)
1321
1519
  return { success = true, noteIndex = note_idx }
1322
1520
  end
1323
1521
 
1522
+ function handlers.edit_midi_notes(params)
1523
+ local track, item, take, err = get_midi_take(params)
1524
+ if err then return nil, err end
1525
+
1526
+ local edits_input = params.edits
1527
+ if not edits_input then return nil, "edits array required" end
1528
+
1529
+ local edits = json_decode_array(edits_input)
1530
+ if not edits then return nil, "Failed to parse edits array" end
1531
+
1532
+ local _, note_cnt, _, _ = reaper.MIDI_CountEvts(take)
1533
+ local to_beats = ppq_to_beats(take, item)
1534
+ local edited = 0
1535
+ local errors = {}
1536
+
1537
+ reaper.Undo_BeginBlock()
1538
+
1539
+ for _, edit in ipairs(edits) do
1540
+ local note_idx = edit.noteIndex
1541
+ if note_idx == nil then
1542
+ errors[#errors + 1] = "edit missing noteIndex"
1543
+ elseif note_idx >= note_cnt then
1544
+ errors[#errors + 1] = "noteIndex " .. note_idx .. " out of range"
1545
+ else
1546
+ local _, sel, muted, cur_start_ppq, cur_end_ppq, cur_chan, cur_pitch, cur_vel = reaper.MIDI_GetNote(take, note_idx)
1547
+
1548
+ local new_pitch = edit.pitch and math.max(0, math.min(127, math.floor(edit.pitch))) or nil
1549
+ local new_vel = edit.velocity and math.max(1, math.min(127, math.floor(edit.velocity))) or nil
1550
+ local new_chan = edit.channel and math.max(0, math.min(15, math.floor(edit.channel))) or nil
1551
+
1552
+ local new_start_ppq = nil
1553
+ local new_end_ppq = nil
1554
+ if edit.startPosition ~= nil then
1555
+ new_start_ppq = beats_to_ppq(take, item, edit.startPosition)
1556
+ if edit.duration ~= nil then
1557
+ new_end_ppq = beats_to_ppq(take, item, edit.startPosition + edit.duration)
1558
+ else
1559
+ local dur_ppq = cur_end_ppq - cur_start_ppq
1560
+ new_end_ppq = new_start_ppq + dur_ppq
1561
+ end
1562
+ elseif edit.duration ~= nil then
1563
+ local cur_start_beats = to_beats(cur_start_ppq)
1564
+ new_end_ppq = beats_to_ppq(take, item, cur_start_beats + edit.duration)
1565
+ end
1566
+
1567
+ reaper.MIDI_SetNote(take, note_idx, sel, muted, new_start_ppq, new_end_ppq, new_chan, new_pitch, new_vel, false)
1568
+ edited = edited + 1
1569
+ end
1570
+ end
1571
+
1572
+ reaper.MIDI_Sort(take)
1573
+ reaper.Undo_EndBlock("MCP: Batch edit " .. edited .. " MIDI notes", -1)
1574
+ reaper.UpdateArrange()
1575
+
1576
+ local result = { success = true, edited = edited, total = #edits }
1577
+ if #errors > 0 then result.errors = errors end
1578
+ return result
1579
+ end
1580
+
1324
1581
  function handlers.delete_midi_note(params)
1325
1582
  local track, item, take, err = get_midi_take(params)
1326
1583
  if err then return nil, err end
@@ -1597,6 +1854,71 @@ function handlers.set_media_item_properties(params)
1597
1854
  return { success = true, trackIndex = params.trackIndex, itemIndex = params.itemIndex }
1598
1855
  end
1599
1856
 
1857
+ function handlers.set_media_items_properties(params)
1858
+ local items_input = params.items
1859
+ if not items_input then return nil, "items array required" end
1860
+
1861
+ local items = json_decode_array(items_input)
1862
+ if not items then return nil, "Failed to parse items array" end
1863
+
1864
+ local edited = 0
1865
+ local errors = {}
1866
+
1867
+ reaper.Undo_BeginBlock()
1868
+
1869
+ for _, item_params in ipairs(items) do
1870
+ local track_idx = item_params.trackIndex
1871
+ local item_idx = item_params.itemIndex
1872
+ if track_idx == nil or item_idx == nil then
1873
+ errors[#errors + 1] = "item missing trackIndex or itemIndex"
1874
+ else
1875
+ local track = reaper.GetTrack(0, track_idx)
1876
+ if not track then
1877
+ errors[#errors + 1] = "Track " .. track_idx .. " not found"
1878
+ else
1879
+ local item_count = reaper.CountTrackMediaItems(track)
1880
+ if item_idx >= item_count then
1881
+ errors[#errors + 1] = "Item " .. item_idx .. " not found on track " .. track_idx
1882
+ else
1883
+ local item = reaper.GetTrackMediaItem(track, item_idx)
1884
+ if item_params.position ~= nil then
1885
+ reaper.SetMediaItemInfo_Value(item, "D_POSITION", item_params.position)
1886
+ end
1887
+ if item_params.length ~= nil then
1888
+ reaper.SetMediaItemInfo_Value(item, "D_LENGTH", item_params.length)
1889
+ end
1890
+ if item_params.volume ~= nil then
1891
+ reaper.SetMediaItemInfo_Value(item, "D_VOL", from_db(item_params.volume))
1892
+ end
1893
+ if item_params.mute ~= nil then
1894
+ reaper.SetMediaItemInfo_Value(item, "B_MUTE", item_params.mute)
1895
+ end
1896
+ if item_params.fadeInLength ~= nil then
1897
+ reaper.SetMediaItemInfo_Value(item, "D_FADEINLEN", item_params.fadeInLength)
1898
+ end
1899
+ if item_params.fadeOutLength ~= nil then
1900
+ reaper.SetMediaItemInfo_Value(item, "D_FADEOUTLEN", item_params.fadeOutLength)
1901
+ end
1902
+ if item_params.playRate ~= nil then
1903
+ local take = reaper.GetActiveTake(item)
1904
+ if take then
1905
+ reaper.SetMediaItemTakeInfo_Value(take, "D_PLAYRATE", item_params.playRate)
1906
+ end
1907
+ end
1908
+ edited = edited + 1
1909
+ end
1910
+ end
1911
+ end
1912
+ end
1913
+
1914
+ reaper.Undo_EndBlock("MCP: Batch set " .. edited .. " media item properties", -1)
1915
+ reaper.UpdateArrange()
1916
+
1917
+ local result = { success = true, edited = edited, total = #items }
1918
+ if #errors > 0 then result.errors = errors end
1919
+ return result
1920
+ end
1921
+
1600
1922
  function handlers.split_media_item(params)
1601
1923
  local track, item, err = get_media_item(params)
1602
1924
  if err then return nil, err end
@@ -1799,6 +2121,509 @@ function handlers.delete_stretch_marker(params)
1799
2121
  return { success = true, totalMarkers = reaper.GetTakeNumStretchMarkers(take) }
1800
2122
  end
1801
2123
 
2124
+ -- =============================================================================
2125
+ -- FX Enable / Offline
2126
+ -- =============================================================================
2127
+
2128
+ function handlers.set_fx_enabled(params)
2129
+ local track = reaper.GetTrack(0, params.trackIndex)
2130
+ if not track then return nil, "Track " .. params.trackIndex .. " not found" end
2131
+ local fx_count = reaper.TrackFX_GetCount(track)
2132
+ if params.fxIndex >= fx_count then
2133
+ return nil, "FX index " .. params.fxIndex .. " out of range (track has " .. fx_count .. " FX)"
2134
+ end
2135
+ reaper.TrackFX_SetEnabled(track, params.fxIndex, params.enabled == 1)
2136
+ return { trackIndex = params.trackIndex, fxIndex = params.fxIndex, enabled = params.enabled == 1 }
2137
+ end
2138
+
2139
+ function handlers.set_fx_offline(params)
2140
+ local track = reaper.GetTrack(0, params.trackIndex)
2141
+ if not track then return nil, "Track " .. params.trackIndex .. " not found" end
2142
+ local fx_count = reaper.TrackFX_GetCount(track)
2143
+ if params.fxIndex >= fx_count then
2144
+ return nil, "FX index " .. params.fxIndex .. " out of range (track has " .. fx_count .. " FX)"
2145
+ end
2146
+ reaper.TrackFX_SetOffline(track, params.fxIndex, params.offline == 1)
2147
+ return { trackIndex = params.trackIndex, fxIndex = params.fxIndex, offline = params.offline == 1 }
2148
+ end
2149
+
2150
+ -- =============================================================================
2151
+ -- Selection & Navigation
2152
+ -- =============================================================================
2153
+
2154
+ function handlers.get_selected_tracks(params)
2155
+ local count = reaper.CountSelectedTracks(0)
2156
+ local tracks = {}
2157
+ for i = 0, count - 1 do
2158
+ local track = reaper.GetSelectedTrack(0, i)
2159
+ local idx = reaper.GetMediaTrackInfo_Value(track, "IP_TRACKNUMBER") - 1
2160
+ local _, name = reaper.GetTrackName(track)
2161
+ tracks[#tracks + 1] = { index = idx, name = name }
2162
+ end
2163
+ return { tracks = tracks, count = count }
2164
+ end
2165
+
2166
+ function handlers.get_time_selection(params)
2167
+ local start_pos, end_pos = reaper.GetSet_LoopTimeRange(false, false, 0, 0, false)
2168
+ return {
2169
+ start = start_pos,
2170
+ ["end"] = end_pos,
2171
+ length = end_pos - start_pos,
2172
+ hasSelection = end_pos > start_pos,
2173
+ }
2174
+ end
2175
+
2176
+ function handlers.set_time_selection(params)
2177
+ if params.start == nil then return nil, "start required" end
2178
+ if params["end"] == nil then return nil, "end required" end
2179
+ reaper.GetSet_LoopTimeRange(true, false, params.start, params["end"], false)
2180
+ return { start = params.start, ["end"] = params["end"] }
2181
+ end
2182
+
2183
+ -- =============================================================================
2184
+ -- Markers & Regions
2185
+ -- =============================================================================
2186
+
2187
+ function handlers.list_markers(params)
2188
+ local _, num_markers, num_regions = reaper.CountProjectMarkers(0)
2189
+ local total = num_markers + num_regions
2190
+ local markers = {}
2191
+ for i = 0, total - 1 do
2192
+ local _, isrgn, pos, rgnend, name, markrgnindexnumber, color = reaper.EnumProjectMarkers3(0, i)
2193
+ if not isrgn then
2194
+ markers[#markers + 1] = {
2195
+ index = markrgnindexnumber,
2196
+ name = name,
2197
+ position = pos,
2198
+ color = color,
2199
+ }
2200
+ end
2201
+ end
2202
+ return { markers = markers, count = #markers }
2203
+ end
2204
+
2205
+ function handlers.list_regions(params)
2206
+ local _, num_markers, num_regions = reaper.CountProjectMarkers(0)
2207
+ local total = num_markers + num_regions
2208
+ local regions = {}
2209
+ for i = 0, total - 1 do
2210
+ local _, isrgn, pos, rgnend, name, markrgnindexnumber, color = reaper.EnumProjectMarkers3(0, i)
2211
+ if isrgn then
2212
+ regions[#regions + 1] = {
2213
+ index = markrgnindexnumber,
2214
+ name = name,
2215
+ start = pos,
2216
+ ["end"] = rgnend,
2217
+ length = rgnend - pos,
2218
+ color = color,
2219
+ }
2220
+ end
2221
+ end
2222
+ return { regions = regions, count = #regions }
2223
+ end
2224
+
2225
+ function handlers.add_marker(params)
2226
+ if params.position == nil then return nil, "position required" end
2227
+ local color = params.color or 0
2228
+ local name = params.name or ""
2229
+ local idx = reaper.AddProjectMarker2(0, false, params.position, 0, name, -1, color)
2230
+ if idx < 0 then return nil, "Failed to add marker" end
2231
+ return { index = idx, position = params.position, name = name }
2232
+ end
2233
+
2234
+ function handlers.add_region(params)
2235
+ if params.start == nil then return nil, "start required" end
2236
+ if params["end"] == nil then return nil, "end required" end
2237
+ local color = params.color or 0
2238
+ local name = params.name or ""
2239
+ local idx = reaper.AddProjectMarker2(0, true, params.start, params["end"], name, -1, color)
2240
+ if idx < 0 then return nil, "Failed to add region" end
2241
+ return { index = idx, start = params.start, ["end"] = params["end"], name = name }
2242
+ end
2243
+
2244
+ function handlers.delete_marker(params)
2245
+ if params.markerIndex == nil then return nil, "markerIndex required" end
2246
+ -- DeleteProjectMarker takes (proj, isrgn, markrgnindexnumber)
2247
+ local deleted = reaper.DeleteProjectMarker(0, false, params.markerIndex, false)
2248
+ if not deleted then return nil, "Marker " .. params.markerIndex .. " not found" end
2249
+ return { success = true }
2250
+ end
2251
+
2252
+ function handlers.delete_region(params)
2253
+ if params.regionIndex == nil then return nil, "regionIndex required" end
2254
+ local deleted = reaper.DeleteProjectMarker(0, true, params.regionIndex, false)
2255
+ if not deleted then return nil, "Region " .. params.regionIndex .. " not found" end
2256
+ return { success = true }
2257
+ end
2258
+
2259
+ -- =============================================================================
2260
+ -- Tempo Map
2261
+ -- =============================================================================
2262
+
2263
+ function handlers.get_tempo_map(params)
2264
+ local count = reaper.CountTempoTimeSigMarkers(0)
2265
+ local markers = {}
2266
+ for i = 0, count - 1 do
2267
+ local _, timepos, measurepos, beatpos, bpm, timesig_num, timesig_denom, lineartempo = reaper.GetTempoTimeSigMarker(0, i)
2268
+ markers[#markers + 1] = {
2269
+ index = i,
2270
+ position = timepos,
2271
+ bpm = bpm,
2272
+ timeSigNumerator = timesig_num,
2273
+ timeSigDenominator = timesig_denom,
2274
+ linearTempo = lineartempo,
2275
+ }
2276
+ end
2277
+ -- If no markers, return the project tempo as the single entry
2278
+ if count == 0 then
2279
+ local bpm = reaper.Master_GetTempo()
2280
+ local _, num, denom = reaper.TimeMap_GetTimeSigAtTime(0, 0)
2281
+ markers[1] = {
2282
+ index = 0,
2283
+ position = 0,
2284
+ bpm = bpm,
2285
+ timeSigNumerator = num,
2286
+ timeSigDenominator = denom,
2287
+ linearTempo = false,
2288
+ }
2289
+ end
2290
+ return { markers = markers, count = #markers }
2291
+ end
2292
+
2293
+ -- =============================================================================
2294
+ -- Envelopes / Automation
2295
+ -- =============================================================================
2296
+
2297
+ function handlers.get_track_envelopes(params)
2298
+ local track = reaper.GetTrack(0, params.trackIndex)
2299
+ if not track then return nil, "Track " .. params.trackIndex .. " not found" end
2300
+ local count = reaper.CountTrackEnvelopes(track)
2301
+ local envelopes = {}
2302
+ for i = 0, count - 1 do
2303
+ local env = reaper.GetTrackEnvelope(track, i)
2304
+ local _, name = reaper.GetEnvelopeName(env)
2305
+ local point_count = reaper.CountEnvelopePoints(env)
2306
+ -- Get envelope state info (SWS extension provides BR_Env*, fallback if unavailable)
2307
+ local active, visible, armed = true, true, false
2308
+ if reaper.BR_EnvAlloc then
2309
+ local br_env = reaper.BR_EnvAlloc(env, false)
2310
+ active, visible, armed = reaper.BR_EnvGetProperties(br_env)
2311
+ reaper.BR_EnvFree(br_env, false)
2312
+ end
2313
+ envelopes[#envelopes + 1] = {
2314
+ index = i,
2315
+ name = name,
2316
+ pointCount = point_count,
2317
+ active = active,
2318
+ visible = visible,
2319
+ armed = armed,
2320
+ }
2321
+ end
2322
+ return { trackIndex = params.trackIndex, envelopes = envelopes, count = count }
2323
+ end
2324
+
2325
+ function handlers.get_envelope_points(params)
2326
+ local track = reaper.GetTrack(0, params.trackIndex)
2327
+ if not track then return nil, "Track " .. params.trackIndex .. " not found" end
2328
+ local env_count = reaper.CountTrackEnvelopes(track)
2329
+ if params.envelopeIndex >= env_count then
2330
+ return nil, "Envelope index " .. params.envelopeIndex .. " out of range (track has " .. env_count .. " envelopes)"
2331
+ end
2332
+ local env = reaper.GetTrackEnvelope(track, params.envelopeIndex)
2333
+ local total = reaper.CountEnvelopePoints(env)
2334
+ local offset = params.offset or 0
2335
+ local limit = params.limit or total
2336
+ local points = {}
2337
+ local end_idx = math.min(offset + limit, total)
2338
+ for i = offset, end_idx - 1 do
2339
+ local _, time, value, shape, tension, selected = reaper.GetEnvelopePoint(env, i)
2340
+ points[#points + 1] = {
2341
+ index = i,
2342
+ time = time,
2343
+ value = value,
2344
+ shape = shape,
2345
+ tension = tension,
2346
+ selected = selected,
2347
+ }
2348
+ end
2349
+ return {
2350
+ trackIndex = params.trackIndex,
2351
+ envelopeIndex = params.envelopeIndex,
2352
+ points = points,
2353
+ total = total,
2354
+ offset = offset,
2355
+ hasMore = end_idx < total,
2356
+ }
2357
+ end
2358
+
2359
+ function handlers.insert_envelope_point(params)
2360
+ local track = reaper.GetTrack(0, params.trackIndex)
2361
+ if not track then return nil, "Track " .. params.trackIndex .. " not found" end
2362
+ local env_count = reaper.CountTrackEnvelopes(track)
2363
+ if params.envelopeIndex >= env_count then
2364
+ return nil, "Envelope index " .. params.envelopeIndex .. " out of range (track has " .. env_count .. " envelopes)"
2365
+ end
2366
+ local env = reaper.GetTrackEnvelope(track, params.envelopeIndex)
2367
+ local shape = params.shape or 0
2368
+ local tension = params.tension or 0
2369
+ reaper.InsertEnvelopePoint(env, params.time, params.value, shape, tension, false, true)
2370
+ reaper.Envelope_SortPoints(env)
2371
+ local total = reaper.CountEnvelopePoints(env)
2372
+ return {
2373
+ trackIndex = params.trackIndex,
2374
+ envelopeIndex = params.envelopeIndex,
2375
+ time = params.time,
2376
+ value = params.value,
2377
+ shape = shape,
2378
+ tension = tension,
2379
+ totalPoints = total,
2380
+ }
2381
+ end
2382
+
2383
+ function handlers.delete_envelope_point(params)
2384
+ local track = reaper.GetTrack(0, params.trackIndex)
2385
+ if not track then return nil, "Track " .. params.trackIndex .. " not found" end
2386
+ local env_count = reaper.CountTrackEnvelopes(track)
2387
+ if params.envelopeIndex >= env_count then
2388
+ return nil, "Envelope index " .. params.envelopeIndex .. " out of range (track has " .. env_count .. " envelopes)"
2389
+ end
2390
+ local env = reaper.GetTrackEnvelope(track, params.envelopeIndex)
2391
+ local total = reaper.CountEnvelopePoints(env)
2392
+ if params.pointIndex >= total then
2393
+ return nil, "Point index " .. params.pointIndex .. " out of range (envelope has " .. total .. " points)"
2394
+ end
2395
+ reaper.DeleteEnvelopePointRange(env, params.pointIndex, params.pointIndex + 1)
2396
+ return { success = true, totalPoints = reaper.CountEnvelopePoints(env) }
2397
+ end
2398
+
2399
+ function handlers.create_track_envelope(params)
2400
+ local track = reaper.GetTrack(0, params.trackIndex)
2401
+ if not track then return nil, "Track " .. params.trackIndex .. " not found" end
2402
+
2403
+ local env = nil
2404
+ local env_name = nil
2405
+
2406
+ if params.fxIndex ~= nil then
2407
+ -- FX parameter envelope
2408
+ if params.paramIndex == nil then
2409
+ return nil, "paramIndex is required when fxIndex is provided"
2410
+ end
2411
+ local fx_count = reaper.TrackFX_GetNumParms(track)
2412
+ if fx_count == 0 then
2413
+ return nil, "Track " .. params.trackIndex .. " has no FX"
2414
+ end
2415
+ -- create=true to create the envelope if it doesn't exist
2416
+ env = reaper.GetFXEnvelope(track, params.fxIndex, params.paramIndex, true)
2417
+ if not env then
2418
+ return nil, "Failed to create FX parameter envelope (fxIndex=" .. params.fxIndex .. ", paramIndex=" .. params.paramIndex .. ")"
2419
+ end
2420
+ local _, pname = reaper.TrackFX_GetParamName(track, params.fxIndex, params.paramIndex)
2421
+ local _, fname = reaper.TrackFX_GetFXName(track, params.fxIndex)
2422
+ env_name = fname .. " / " .. pname
2423
+ elseif params.envelopeName then
2424
+ -- Built-in envelope: use GetTrackEnvelopeByName + show via state chunk
2425
+ env = reaper.GetTrackEnvelopeByName(track, params.envelopeName)
2426
+ if not env then
2427
+ -- Some envelopes need to be activated first via the track state chunk
2428
+ -- Toggle the envelope visibility via action or chunk editing
2429
+ local env_map = {
2430
+ ["Volume"] = "VOLENV",
2431
+ ["Pan"] = "PANENV",
2432
+ ["Mute"] = "MUTEENV",
2433
+ ["Width"] = "WIDTHENV",
2434
+ ["Trim Volume"] = "VOLENV2",
2435
+ }
2436
+ local chunk_key = env_map[params.envelopeName]
2437
+ if not chunk_key then
2438
+ return nil, "Unknown envelope name: " .. params.envelopeName .. ". Use Volume, Pan, Mute, Width, or Trim Volume"
2439
+ end
2440
+ -- Get track chunk, insert envelope chunk if missing
2441
+ local _, chunk = reaper.GetTrackStateChunk(track, "", false)
2442
+ if not chunk:find(chunk_key) then
2443
+ -- Insert a minimal envelope chunk before the closing >
2444
+ local env_chunk = "\n<" .. chunk_key .. "\nACT 1 -1\nVIS 1 1 1\nLANEHEIGHT 0 0\nARM 0\nDEFSHAPE 0 -1 -1\n>\n"
2445
+ chunk = chunk:gsub("\n>$", env_chunk .. ">")
2446
+ reaper.SetTrackStateChunk(track, chunk, false)
2447
+ else
2448
+ -- Envelope exists in chunk but may be hidden; make it visible
2449
+ chunk = chunk:gsub("(" .. chunk_key .. "[^\n]*\n)ACT 0", "%1ACT 1")
2450
+ chunk = chunk:gsub("(" .. chunk_key .. "[^\n]*\n[^\n]*\n)VIS 0", "%1VIS 1")
2451
+ reaper.SetTrackStateChunk(track, chunk, false)
2452
+ end
2453
+ env = reaper.GetTrackEnvelopeByName(track, params.envelopeName)
2454
+ if not env then
2455
+ return nil, "Failed to create envelope: " .. params.envelopeName
2456
+ end
2457
+ else
2458
+ -- Envelope exists, ensure it's visible and active
2459
+ local _, chunk = reaper.GetEnvelopeStateChunk(env, "", false)
2460
+ chunk = chunk:gsub("ACT 0", "ACT 1")
2461
+ chunk = chunk:gsub("VIS 0", "VIS 1")
2462
+ reaper.SetEnvelopeStateChunk(env, chunk, false)
2463
+ end
2464
+ env_name = params.envelopeName
2465
+ else
2466
+ return nil, "Must provide either envelopeName or fxIndex+paramIndex"
2467
+ end
2468
+
2469
+ -- Find the index of the newly created envelope
2470
+ local env_count = reaper.CountTrackEnvelopes(track)
2471
+ local env_index = -1
2472
+ for i = 0, env_count - 1 do
2473
+ if reaper.GetTrackEnvelope(track, i) == env then
2474
+ env_index = i
2475
+ break
2476
+ end
2477
+ end
2478
+
2479
+ reaper.TrackList_AdjustWindows(false)
2480
+ reaper.UpdateArrange()
2481
+
2482
+ return {
2483
+ trackIndex = params.trackIndex,
2484
+ envelopeIndex = env_index,
2485
+ name = env_name,
2486
+ pointCount = reaper.CountEnvelopePoints(env),
2487
+ }
2488
+ end
2489
+
2490
+ function handlers.set_envelope_properties(params)
2491
+ local track = reaper.GetTrack(0, params.trackIndex)
2492
+ if not track then return nil, "Track " .. params.trackIndex .. " not found" end
2493
+ local env_count = reaper.CountTrackEnvelopes(track)
2494
+ if params.envelopeIndex >= env_count then
2495
+ return nil, "Envelope index " .. params.envelopeIndex .. " out of range (track has " .. env_count .. " envelopes)"
2496
+ end
2497
+ local env = reaper.GetTrackEnvelope(track, params.envelopeIndex)
2498
+
2499
+ if reaper.BR_EnvAlloc then
2500
+ -- SWS extension available: use BR_Env* functions
2501
+ local br_env = reaper.BR_EnvAlloc(env, false)
2502
+ local cur_active, cur_visible, cur_armed = reaper.BR_EnvGetProperties(br_env)
2503
+ local new_active = params.active ~= nil and params.active or cur_active
2504
+ local new_visible = params.visible ~= nil and params.visible or cur_visible
2505
+ local new_armed = params.armed ~= nil and params.armed or cur_armed
2506
+ reaper.BR_EnvSetProperties(br_env, new_active, new_visible, new_armed, false)
2507
+ reaper.BR_EnvFree(br_env, true) -- commit=true
2508
+ else
2509
+ -- Fallback: modify envelope state chunk directly
2510
+ local _, chunk = reaper.GetEnvelopeStateChunk(env, "", false)
2511
+ if params.active ~= nil then
2512
+ local val = params.active and "1" or "0"
2513
+ chunk = chunk:gsub("ACT %d", "ACT " .. val)
2514
+ end
2515
+ if params.visible ~= nil then
2516
+ local val = params.visible and "1" or "0"
2517
+ chunk = chunk:gsub("VIS %d", "VIS " .. val)
2518
+ end
2519
+ if params.armed ~= nil then
2520
+ local val = params.armed and "1" or "0"
2521
+ chunk = chunk:gsub("ARM %d", "ARM " .. val)
2522
+ end
2523
+ reaper.SetEnvelopeStateChunk(env, chunk, false)
2524
+ end
2525
+
2526
+ reaper.TrackList_AdjustWindows(false)
2527
+ reaper.UpdateArrange()
2528
+
2529
+ local _, name = reaper.GetEnvelopeName(env)
2530
+ -- Re-read properties
2531
+ local active, visible, armed = true, true, false
2532
+ if reaper.BR_EnvAlloc then
2533
+ local br_env = reaper.BR_EnvAlloc(env, false)
2534
+ active, visible, armed = reaper.BR_EnvGetProperties(br_env)
2535
+ reaper.BR_EnvFree(br_env, false)
2536
+ end
2537
+ return {
2538
+ trackIndex = params.trackIndex,
2539
+ envelopeIndex = params.envelopeIndex,
2540
+ name = name,
2541
+ active = active,
2542
+ visible = visible,
2543
+ armed = armed,
2544
+ }
2545
+ end
2546
+
2547
+ function handlers.clear_envelope(params)
2548
+ local track = reaper.GetTrack(0, params.trackIndex)
2549
+ if not track then return nil, "Track " .. params.trackIndex .. " not found" end
2550
+ local env_count = reaper.CountTrackEnvelopes(track)
2551
+ if params.envelopeIndex >= env_count then
2552
+ return nil, "Envelope index " .. params.envelopeIndex .. " out of range (track has " .. env_count .. " envelopes)"
2553
+ end
2554
+ local env = reaper.GetTrackEnvelope(track, params.envelopeIndex)
2555
+ local prev_count = reaper.CountEnvelopePoints(env)
2556
+ -- Delete all points by using a range from -infinity to +infinity
2557
+ reaper.DeleteEnvelopePointRange(env, -math.huge, math.huge)
2558
+ reaper.Envelope_SortPoints(env)
2559
+ return {
2560
+ trackIndex = params.trackIndex,
2561
+ envelopeIndex = params.envelopeIndex,
2562
+ deletedPoints = prev_count,
2563
+ totalPoints = reaper.CountEnvelopePoints(env),
2564
+ }
2565
+ end
2566
+
2567
+ function handlers.remove_envelope_points(params)
2568
+ local track = reaper.GetTrack(0, params.trackIndex)
2569
+ if not track then return nil, "Track " .. params.trackIndex .. " not found" end
2570
+ local env_count = reaper.CountTrackEnvelopes(track)
2571
+ if params.envelopeIndex >= env_count then
2572
+ return nil, "Envelope index " .. params.envelopeIndex .. " out of range (track has " .. env_count .. " envelopes)"
2573
+ end
2574
+ local env = reaper.GetTrackEnvelope(track, params.envelopeIndex)
2575
+ local prev_count = reaper.CountEnvelopePoints(env)
2576
+ reaper.DeleteEnvelopePointRange(env, params.timeStart, params.timeEnd)
2577
+ reaper.Envelope_SortPoints(env)
2578
+ local new_count = reaper.CountEnvelopePoints(env)
2579
+ return {
2580
+ trackIndex = params.trackIndex,
2581
+ envelopeIndex = params.envelopeIndex,
2582
+ timeStart = params.timeStart,
2583
+ timeEnd = params.timeEnd,
2584
+ deletedPoints = prev_count - new_count,
2585
+ totalPoints = new_count,
2586
+ }
2587
+ end
2588
+
2589
+ function handlers.insert_envelope_points(params)
2590
+ local track = reaper.GetTrack(0, params.trackIndex)
2591
+ if not track then return nil, "Track " .. params.trackIndex .. " not found" end
2592
+ local env_count = reaper.CountTrackEnvelopes(track)
2593
+ if params.envelopeIndex >= env_count then
2594
+ return nil, "Envelope index " .. params.envelopeIndex .. " out of range (track has " .. env_count .. " envelopes)"
2595
+ end
2596
+ local env = reaper.GetTrackEnvelope(track, params.envelopeIndex)
2597
+
2598
+ -- Parse the points JSON string
2599
+ local points_str = params.points
2600
+ local points = nil
2601
+ if type(points_str) == "string" then
2602
+ points = json_decode(points_str)
2603
+ elseif type(points_str) == "table" then
2604
+ points = points_str
2605
+ end
2606
+ if not points then return nil, "Failed to parse points JSON" end
2607
+
2608
+ local inserted = 0
2609
+ for _, pt in ipairs(points) do
2610
+ if pt.time and pt.value then
2611
+ local shape = pt.shape or 0
2612
+ local tension = pt.tension or 0
2613
+ reaper.InsertEnvelopePoint(env, pt.time, pt.value, shape, tension, false, true)
2614
+ inserted = inserted + 1
2615
+ end
2616
+ end
2617
+
2618
+ reaper.Envelope_SortPoints(env)
2619
+ return {
2620
+ trackIndex = params.trackIndex,
2621
+ envelopeIndex = params.envelopeIndex,
2622
+ insertedPoints = inserted,
2623
+ totalPoints = reaper.CountEnvelopePoints(env),
2624
+ }
2625
+ end
2626
+
1802
2627
  -- =============================================================================
1803
2628
  -- Command dispatcher
1804
2629
  -- =============================================================================