@mthines/reaper-mcp 0.13.0 → 0.14.1

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/main.js CHANGED
@@ -1875,6 +1875,270 @@ function registerEnvelopeTools(server) {
1875
1875
  );
1876
1876
  }
1877
1877
 
1878
+ // apps/reaper-mcp-server/src/tools/batch.ts
1879
+ import { z as z15 } from "zod/v4";
1880
+ function registerBatchTools(server) {
1881
+ server.tool(
1882
+ "set_multiple_track_properties",
1883
+ "Batch set properties (volume, pan, mute, solo, recordArm, phase, input) on multiple tracks in a single call. Much more efficient than calling set_track_property repeatedly. Pass an array of track update objects, each with trackIndex and any properties to change.",
1884
+ {
1885
+ tracks: z15.array(z15.object({
1886
+ trackIndex: z15.coerce.number().int().min(0).describe("0-based track index"),
1887
+ volume: z15.coerce.number().optional().describe("Volume in dB (0 = unity gain)"),
1888
+ pan: z15.coerce.number().min(-1).max(1).optional().describe("Pan position -1.0 (left) to 1.0 (right)"),
1889
+ mute: z15.coerce.number().int().min(0).max(1).optional().describe("Mute state (0=unmuted, 1=muted)"),
1890
+ solo: z15.coerce.number().int().min(0).max(1).optional().describe("Solo state (0=unsolo, 1=solo)"),
1891
+ recordArm: z15.coerce.number().int().min(0).max(1).optional().describe("Record arm state (0=unarmed, 1=armed)"),
1892
+ phase: z15.coerce.number().int().min(0).max(1).optional().describe("Phase inversion (0=normal, 1=inverted)"),
1893
+ input: z15.coerce.number().int().optional().describe("REAPER input index (-1 = no input)")
1894
+ })).describe("Array of track property updates to apply")
1895
+ },
1896
+ async ({ tracks }) => {
1897
+ const res = await sendCommand("set_multiple_track_properties", { tracks });
1898
+ if (!res.success) {
1899
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
1900
+ }
1901
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
1902
+ }
1903
+ );
1904
+ server.tool(
1905
+ "setup_fx_chain",
1906
+ "Add multiple FX plugins to a track with optional initial parameter values in a single call. Returns the FX chain indices of added plugins. More efficient than calling add_fx and set_fx_parameter repeatedly.",
1907
+ {
1908
+ trackIndex: z15.coerce.number().int().min(0).describe("0-based track index"),
1909
+ plugins: z15.array(z15.object({
1910
+ fxName: z15.string().describe('FX plugin name (partial match supported, e.g. "ReaEQ", "VST: Pro-Q 3")'),
1911
+ enabled: z15.coerce.number().int().min(0).max(1).optional().describe("Initial enabled state (1=enabled default, 0=bypassed)"),
1912
+ parameters: z15.array(z15.object({
1913
+ index: z15.coerce.number().int().min(0).describe("Parameter index"),
1914
+ value: z15.coerce.number().min(0).max(1).describe("Normalized parameter value 0.0\u20131.0")
1915
+ })).optional().describe("Initial parameter values to set after adding the plugin")
1916
+ })).describe("Array of FX plugins to add, in order")
1917
+ },
1918
+ async ({ trackIndex, plugins }) => {
1919
+ const res = await sendCommand("setup_fx_chain", { trackIndex, plugins });
1920
+ if (!res.success) {
1921
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
1922
+ }
1923
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
1924
+ }
1925
+ );
1926
+ server.tool(
1927
+ "set_multiple_fx_parameters",
1928
+ "Batch set parameters across multiple FX plugins, potentially on different tracks, in a single call. Much faster than repeated set_fx_parameter calls. Pass an array of update objects with trackIndex, fxIndex, paramIndex, and value.",
1929
+ {
1930
+ updates: z15.array(z15.object({
1931
+ trackIndex: z15.coerce.number().int().min(0).describe("0-based track index"),
1932
+ fxIndex: z15.coerce.number().int().min(0).describe("0-based FX index in the chain"),
1933
+ paramIndex: z15.coerce.number().int().min(0).describe("0-based parameter index"),
1934
+ value: z15.coerce.number().min(0).max(1).describe("Normalized parameter value 0.0\u20131.0")
1935
+ })).describe("Array of FX parameter updates to apply")
1936
+ },
1937
+ async ({ updates }) => {
1938
+ const res = await sendCommand("set_multiple_fx_parameters", { updates });
1939
+ if (!res.success) {
1940
+ return { content: [{ type: "text", text: `Error: ${res.error}` }], isError: true };
1941
+ }
1942
+ return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
1943
+ }
1944
+ );
1945
+ }
1946
+
1947
+ // apps/reaper-mcp-server/src/tools/categories.ts
1948
+ import { z as z16 } from "zod/v4";
1949
+ var TOOL_CATEGORIES = {
1950
+ project: {
1951
+ name: "project",
1952
+ description: "Project-level information: name, tempo, time signature, sample rate, transport state",
1953
+ tools: ["get_project_info"]
1954
+ },
1955
+ tracks: {
1956
+ name: "tracks",
1957
+ description: "Track management: list, inspect, and set properties (volume, pan, mute, solo, record arm, phase, input). Includes batch set for multiple tracks at once.",
1958
+ tools: ["list_tracks", "get_track_properties", "set_track_property", "set_multiple_track_properties"]
1959
+ },
1960
+ fx: {
1961
+ name: "fx",
1962
+ description: "FX chain management: add, remove, inspect, and set parameters. Includes batch setup of an entire FX chain and batch parameter updates across multiple plugins.",
1963
+ tools: ["add_fx", "remove_fx", "get_fx_parameters", "set_fx_parameter", "set_fx_enabled", "set_fx_offline", "setup_fx_chain", "set_multiple_fx_parameters"]
1964
+ },
1965
+ transport: {
1966
+ name: "transport",
1967
+ description: "Transport control: play, stop, record, get transport state, set cursor position",
1968
+ tools: ["play", "stop", "record", "get_transport_state", "set_cursor_position"]
1969
+ },
1970
+ midi: {
1971
+ name: "midi",
1972
+ description: "MIDI item editing: create items, insert/edit/delete notes and CC events, batch operations, analysis. Use analyze_midi first for large items; paginate with get_midi_notes offset/limit.",
1973
+ tools: [
1974
+ "create_midi_item",
1975
+ "list_midi_items",
1976
+ "get_midi_notes",
1977
+ "analyze_midi",
1978
+ "insert_midi_note",
1979
+ "insert_midi_notes",
1980
+ "edit_midi_note",
1981
+ "edit_midi_notes",
1982
+ "delete_midi_note",
1983
+ "get_midi_cc",
1984
+ "insert_midi_cc",
1985
+ "delete_midi_cc",
1986
+ "get_midi_item_properties",
1987
+ "set_midi_item_properties"
1988
+ ]
1989
+ },
1990
+ media: {
1991
+ name: "media",
1992
+ description: "Media item editing: list, inspect, set properties, split, delete, move, trim, stretch markers. Includes batch set for multiple items at once.",
1993
+ tools: [
1994
+ "list_media_items",
1995
+ "get_media_item_properties",
1996
+ "set_media_item_properties",
1997
+ "set_media_items_properties",
1998
+ "split_media_item",
1999
+ "delete_media_item",
2000
+ "move_media_item",
2001
+ "trim_media_item",
2002
+ "add_stretch_marker",
2003
+ "get_stretch_markers",
2004
+ "delete_stretch_marker"
2005
+ ]
2006
+ },
2007
+ selection: {
2008
+ name: "selection",
2009
+ description: "Selection and navigation: get selected tracks, get/set time selection range",
2010
+ tools: ["get_selected_tracks", "get_time_selection", "set_time_selection"]
2011
+ },
2012
+ markers: {
2013
+ name: "markers",
2014
+ description: "Markers and regions: list, add, delete markers and regions with optional names and colors",
2015
+ tools: ["list_markers", "list_regions", "add_marker", "add_region", "delete_marker", "delete_region"]
2016
+ },
2017
+ tempo: {
2018
+ name: "tempo",
2019
+ description: "Tempo map: get all tempo and time signature changes with positions, BPM, and linear/step flags",
2020
+ tools: ["get_tempo_map"]
2021
+ },
2022
+ envelopes: {
2023
+ name: "envelopes",
2024
+ description: "Automation envelopes: create, inspect, insert/delete points, clear, batch insert, set properties. Supports volume, pan, mute, width, trim volume, and FX parameter envelopes.",
2025
+ tools: [
2026
+ "create_track_envelope",
2027
+ "get_track_envelopes",
2028
+ "get_envelope_points",
2029
+ "insert_envelope_point",
2030
+ "insert_envelope_points",
2031
+ "delete_envelope_point",
2032
+ "clear_envelope",
2033
+ "remove_envelope_points",
2034
+ "set_envelope_properties"
2035
+ ]
2036
+ },
2037
+ analysis: {
2038
+ name: "analysis",
2039
+ description: "Audio metering and analysis: peak/RMS meters, FFT spectrum, LUFS loudness, stereo correlation, crest factor",
2040
+ tools: ["read_track_meters", "read_track_spectrum", "read_track_lufs", "read_track_correlation", "read_track_crest"]
2041
+ },
2042
+ discovery: {
2043
+ name: "discovery",
2044
+ description: "FX discovery and presets: list all installed plugins, fuzzy search by name, list and load presets",
2045
+ tools: ["list_available_fx", "search_fx", "get_fx_preset_list", "set_fx_preset"]
2046
+ },
2047
+ snapshots: {
2048
+ name: "snapshots",
2049
+ description: "Mixer state snapshots: save, restore, and list named snapshots of volumes, pans, FX, and mutes",
2050
+ tools: ["snapshot_save", "snapshot_restore", "snapshot_list"]
2051
+ },
2052
+ routing: {
2053
+ name: "routing",
2054
+ description: "Track routing: inspect sends, receives, parent/folder relationships for a track",
2055
+ tools: ["get_track_routing"]
2056
+ }
2057
+ };
2058
+ function registerCategoryTools(server) {
2059
+ server.tool(
2060
+ "list_tool_categories",
2061
+ "List all available tool categories with descriptions and tool names. Use this to discover what tools are available without loading all 72+ tool schemas. Categories: project, tracks, fx, transport, midi, media, selection, markers, tempo, envelopes, analysis, discovery, snapshots, routing.",
2062
+ {},
2063
+ async () => {
2064
+ const categories = Object.values(TOOL_CATEGORIES).map((cat) => ({
2065
+ name: cat.name,
2066
+ description: cat.description,
2067
+ toolCount: cat.tools.length,
2068
+ tools: cat.tools
2069
+ }));
2070
+ return {
2071
+ content: [{
2072
+ type: "text",
2073
+ text: JSON.stringify({ categories, totalTools: categories.reduce((sum, c) => sum + c.toolCount, 0) }, null, 2)
2074
+ }]
2075
+ };
2076
+ }
2077
+ );
2078
+ server.tool(
2079
+ "enable_tool_category",
2080
+ "Signal that you intend to use tools from a specific category. All tools are already registered and available \u2014 this call returns the full list of tools in the category so you know exactly which tool names to use. Use list_tool_categories first to see all available categories.",
2081
+ {
2082
+ category: z16.string().describe('Category name (e.g. "tracks", "fx", "midi", "media", "transport", "markers", "envelopes", "analysis", "discovery")')
2083
+ },
2084
+ async ({ category }) => {
2085
+ const cat = TOOL_CATEGORIES[category];
2086
+ if (!cat) {
2087
+ const available = Object.keys(TOOL_CATEGORIES).join(", ");
2088
+ return {
2089
+ content: [{
2090
+ type: "text",
2091
+ text: `Error: Unknown category "${category}". Available categories: ${available}`
2092
+ }],
2093
+ isError: true
2094
+ };
2095
+ }
2096
+ return {
2097
+ content: [{
2098
+ type: "text",
2099
+ text: JSON.stringify({
2100
+ category: cat.name,
2101
+ description: cat.description,
2102
+ toolCount: cat.tools.length,
2103
+ tools: cat.tools,
2104
+ status: "ready"
2105
+ }, null, 2)
2106
+ }]
2107
+ };
2108
+ }
2109
+ );
2110
+ server.tool(
2111
+ "disable_tool_category",
2112
+ "Signal that you are done using tools from a specific category. This is a semantic hint to help manage your context budget \u2014 the tools remain registered but you can use this to indicate you no longer need them in the current workflow.",
2113
+ {
2114
+ category: z16.string().describe('Category name (e.g. "tracks", "fx", "midi", "media", "transport", "markers", "envelopes", "analysis", "discovery")')
2115
+ },
2116
+ async ({ category }) => {
2117
+ const cat = TOOL_CATEGORIES[category];
2118
+ if (!cat) {
2119
+ const available = Object.keys(TOOL_CATEGORIES).join(", ");
2120
+ return {
2121
+ content: [{
2122
+ type: "text",
2123
+ text: `Error: Unknown category "${category}". Available categories: ${available}`
2124
+ }],
2125
+ isError: true
2126
+ };
2127
+ }
2128
+ return {
2129
+ content: [{
2130
+ type: "text",
2131
+ text: JSON.stringify({
2132
+ category: cat.name,
2133
+ status: "disabled",
2134
+ note: "Tools remain available if needed. Use enable_tool_category to re-activate."
2135
+ }, null, 2)
2136
+ }]
2137
+ };
2138
+ }
2139
+ );
2140
+ }
2141
+
1878
2142
  // apps/reaper-mcp-server/src/server.ts
1879
2143
  function instrumentToolHandlers(server) {
1880
2144
  const originalTool = server.tool.bind(server);
@@ -1932,6 +2196,8 @@ function createServer() {
1932
2196
  registerMarkerTools(server);
1933
2197
  registerTempoTools(server);
1934
2198
  registerEnvelopeTools(server);
2199
+ registerBatchTools(server);
2200
+ registerCategoryTools(server);
1935
2201
  return server;
1936
2202
  }
1937
2203
 
@@ -2091,7 +2357,15 @@ var MCP_TOOL_NAMES = [
2091
2357
  "create_track_envelope",
2092
2358
  "set_envelope_properties",
2093
2359
  "clear_envelope",
2094
- "remove_envelope_points"
2360
+ "remove_envelope_points",
2361
+ // composite batch tools
2362
+ "set_multiple_track_properties",
2363
+ "setup_fx_chain",
2364
+ "set_multiple_fx_parameters",
2365
+ // progressive discovery
2366
+ "list_tool_categories",
2367
+ "enable_tool_category",
2368
+ "disable_tool_category"
2095
2369
  ];
2096
2370
  function ensureClaudeSettings(settingsPath) {
2097
2371
  const allowList = MCP_TOOL_NAMES.map((t) => `mcp__reaper__${t}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mthines/reaper-mcp",
3
- "version": "0.13.0",
3
+ "version": "0.14.1",
4
4
  "type": "module",
5
5
  "description": "MCP server for controlling REAPER DAW — real-time mixing, FX control, and frequency analysis for AI agents",
6
6
  "license": "MIT",
@@ -1000,8 +1000,25 @@ function handlers.snapshot_restore(params)
1000
1000
 
1001
1001
  local restored = 0
1002
1002
 
1003
- if state.tracks then
1004
- for _, track_state in ipairs(state.tracks) do
1003
+ -- Decode tracks array if the fallback JSON parser left it as a raw string
1004
+ local tracks = state.tracks
1005
+ if type(tracks) == "string" then
1006
+ tracks = json_decode_array(tracks)
1007
+ end
1008
+
1009
+ if tracks then
1010
+ for _, track_state in ipairs(tracks) do
1011
+ -- Decode nested arrays that may also be raw strings from fallback parser
1012
+ if type(track_state.fx) == "string" then
1013
+ track_state.fx = json_decode_array(track_state.fx)
1014
+ end
1015
+ if type(track_state.sends) == "string" then
1016
+ track_state.sends = json_decode_array(track_state.sends)
1017
+ end
1018
+ if type(track_state.fxEnabled) == "string" then
1019
+ track_state.fxEnabled = json_decode_array(track_state.fxEnabled)
1020
+ end
1021
+
1005
1022
  local track = reaper.GetTrack(0, track_state.index)
1006
1023
  if track then
1007
1024
  -- Basic mixer state (all versions)
@@ -1240,11 +1257,14 @@ function handlers.read_track_lufs(params)
1240
1257
  local fx_idx, err = ensure_jsfx_on_track(track, MCP_LUFS_METER_FX_NAME)
1241
1258
  if not fx_idx then return nil, err end
1242
1259
 
1243
- -- Set the track_slot parameter (slider2, param index 1) so this instance
1244
- -- writes to a unique gmem offset and doesn't collide with other tracks
1245
- reaper.TrackFX_SetParam(track, fx_idx, 1, idx / 127)
1260
+ -- Set the track_slot parameter so this instance writes to a unique gmem offset
1261
+ local desired_slot = idx / 127
1262
+ local current_slot = reaper.TrackFX_GetParam(track, fx_idx, 1)
1263
+ if math.abs(current_slot - desired_slot) > 0.001 then
1264
+ reaper.TrackFX_SetParam(track, fx_idx, 1, desired_slot)
1265
+ end
1246
1266
 
1247
- -- Attach to the LUFS meter gmem namespace and read from track-specific offset
1267
+ -- Read from gmem (JSFX writes here from @sample)
1248
1268
  reaper.gmem_attach("MCPLufsMeter")
1249
1269
 
1250
1270
  local base = idx * 8
@@ -1277,6 +1297,7 @@ function handlers.read_track_correlation(params)
1277
1297
  local fx_idx, err = ensure_jsfx_on_track(track, MCP_CORRELATION_METER_FX_NAME)
1278
1298
  if not fx_idx then return nil, err end
1279
1299
 
1300
+ -- Read from gmem (JSFX writes here from @sample)
1280
1301
  reaper.gmem_attach("MCPCorrelationMeter")
1281
1302
 
1282
1303
  local correlation = reaper.gmem_read(0)
@@ -1303,6 +1324,7 @@ function handlers.read_track_crest(params)
1303
1324
  local fx_idx, err = ensure_jsfx_on_track(track, MCP_CREST_FACTOR_FX_NAME)
1304
1325
  if not fx_idx then return nil, err end
1305
1326
 
1327
+ -- Read from gmem (JSFX writes here from @sample)
1306
1328
  reaper.gmem_attach("MCPCrestFactor")
1307
1329
 
1308
1330
  local crest_factor = reaper.gmem_read(0)
@@ -2352,6 +2374,196 @@ function handlers.set_fx_offline(params)
2352
2374
  return { trackIndex = params.trackIndex, fxIndex = params.fxIndex, offline = params.offline == 1 }
2353
2375
  end
2354
2376
 
2377
+ -- =============================================================================
2378
+ -- Composite batch tools
2379
+ -- =============================================================================
2380
+
2381
+ function handlers.set_multiple_track_properties(params)
2382
+ local tracks_input = params.tracks
2383
+ if not tracks_input then return nil, "tracks array required" end
2384
+
2385
+ local tracks = nil
2386
+ if type(tracks_input) == "string" then
2387
+ tracks = json_decode(tracks_input)
2388
+ elseif type(tracks_input) == "table" then
2389
+ tracks = tracks_input
2390
+ end
2391
+ if not tracks then return nil, "Failed to parse tracks array" end
2392
+
2393
+ local updated = 0
2394
+ local errors = {}
2395
+
2396
+ reaper.Undo_BeginBlock()
2397
+
2398
+ for _, t in ipairs(tracks) do
2399
+ local track_idx = t.trackIndex
2400
+ if track_idx == nil then
2401
+ errors[#errors + 1] = "track entry missing trackIndex"
2402
+ else
2403
+ local track = reaper.GetTrack(0, track_idx)
2404
+ if not track then
2405
+ errors[#errors + 1] = "Track " .. track_idx .. " not found"
2406
+ else
2407
+ if t.volume ~= nil then
2408
+ reaper.SetMediaTrackInfo_Value(track, "D_VOL", from_db(t.volume))
2409
+ end
2410
+ if t.pan ~= nil then
2411
+ reaper.SetMediaTrackInfo_Value(track, "D_PAN", t.pan)
2412
+ end
2413
+ if t.mute ~= nil then
2414
+ reaper.SetMediaTrackInfo_Value(track, "B_MUTE", t.mute)
2415
+ end
2416
+ if t.solo ~= nil then
2417
+ reaper.SetMediaTrackInfo_Value(track, "I_SOLO", t.solo)
2418
+ end
2419
+ if t.recordArm ~= nil then
2420
+ reaper.SetMediaTrackInfo_Value(track, "I_RECARM", t.recordArm)
2421
+ end
2422
+ if t.phase ~= nil then
2423
+ reaper.SetMediaTrackInfo_Value(track, "B_PHASE", t.phase)
2424
+ end
2425
+ if t.input ~= nil then
2426
+ reaper.SetMediaTrackInfo_Value(track, "I_RECINPUT", t.input)
2427
+ end
2428
+ updated = updated + 1
2429
+ end
2430
+ end
2431
+ end
2432
+
2433
+ reaper.Undo_EndBlock("MCP: Batch set " .. updated .. " track properties", -1)
2434
+
2435
+ local result = { success = true, updated = updated, total = #tracks }
2436
+ if #errors > 0 then result.errors = errors end
2437
+ return result
2438
+ end
2439
+
2440
+ function handlers.setup_fx_chain(params)
2441
+ local track_idx = params.trackIndex
2442
+ if track_idx == nil then return nil, "trackIndex required" end
2443
+
2444
+ local plugins_input = params.plugins
2445
+ if not plugins_input then return nil, "plugins array required" end
2446
+
2447
+ local plugins = nil
2448
+ if type(plugins_input) == "string" then
2449
+ plugins = json_decode(plugins_input)
2450
+ elseif type(plugins_input) == "table" then
2451
+ plugins = plugins_input
2452
+ end
2453
+ if not plugins then return nil, "Failed to parse plugins array" end
2454
+
2455
+ local track = reaper.GetTrack(0, track_idx)
2456
+ if not track then return nil, "Track " .. track_idx .. " not found" end
2457
+
2458
+ local added = 0
2459
+ local errors = {}
2460
+ local added_plugins = {}
2461
+
2462
+ reaper.Undo_BeginBlock()
2463
+
2464
+ for i, plugin in ipairs(plugins) do
2465
+ local fx_name = plugin.fxName
2466
+ if not fx_name or fx_name == "" then
2467
+ errors[#errors + 1] = "plugin[" .. (i - 1) .. "] missing fxName"
2468
+ else
2469
+ local fx_idx = reaper.TrackFX_AddByName(track, fx_name, false, -1)
2470
+ if fx_idx < 0 then
2471
+ errors[#errors + 1] = "FX not found: " .. fx_name
2472
+ else
2473
+ -- Set enabled state if specified (default is enabled)
2474
+ if plugin.enabled ~= nil then
2475
+ reaper.TrackFX_SetEnabled(track, fx_idx, plugin.enabled == 1)
2476
+ end
2477
+
2478
+ -- Set initial parameters if provided
2479
+ local param_errors = {}
2480
+ if plugin.parameters and type(plugin.parameters) == "table" then
2481
+ for _, p in ipairs(plugin.parameters) do
2482
+ if p.index ~= nil and p.value ~= nil then
2483
+ local ok = reaper.TrackFX_SetParam(track, fx_idx, p.index, p.value)
2484
+ if not ok then
2485
+ param_errors[#param_errors + 1] = "Failed to set param " .. p.index
2486
+ end
2487
+ end
2488
+ end
2489
+ end
2490
+
2491
+ local _, actual_name = reaper.TrackFX_GetFXName(track, fx_idx)
2492
+ added_plugins[#added_plugins + 1] = {
2493
+ pluginIndex = i - 1,
2494
+ fxName = actual_name or fx_name,
2495
+ fxIndex = fx_idx,
2496
+ }
2497
+ if #param_errors > 0 then
2498
+ for _, pe in ipairs(param_errors) do
2499
+ errors[#errors + 1] = fx_name .. ": " .. pe
2500
+ end
2501
+ end
2502
+ added = added + 1
2503
+ end
2504
+ end
2505
+ end
2506
+
2507
+ reaper.Undo_EndBlock("MCP: Setup FX chain (" .. added .. " plugins) on track " .. track_idx, -1)
2508
+
2509
+ local result = {
2510
+ success = true,
2511
+ trackIndex = track_idx,
2512
+ added = added,
2513
+ total = #plugins,
2514
+ plugins = added_plugins,
2515
+ }
2516
+ if #errors > 0 then result.errors = errors end
2517
+ return result
2518
+ end
2519
+
2520
+ function handlers.set_multiple_fx_parameters(params)
2521
+ local updates_input = params.updates
2522
+ if not updates_input then return nil, "updates array required" end
2523
+
2524
+ local updates = nil
2525
+ if type(updates_input) == "string" then
2526
+ updates = json_decode(updates_input)
2527
+ elseif type(updates_input) == "table" then
2528
+ updates = updates_input
2529
+ end
2530
+ if not updates then return nil, "Failed to parse updates array" end
2531
+
2532
+ local updated = 0
2533
+ local errors = {}
2534
+
2535
+ reaper.Undo_BeginBlock()
2536
+
2537
+ for _, u in ipairs(updates) do
2538
+ local track_idx = u.trackIndex
2539
+ local fx_idx = u.fxIndex
2540
+ local param_idx = u.paramIndex
2541
+ local value = u.value
2542
+
2543
+ if track_idx == nil or fx_idx == nil or param_idx == nil or value == nil then
2544
+ errors[#errors + 1] = "update entry missing trackIndex, fxIndex, paramIndex, or value"
2545
+ else
2546
+ local track = reaper.GetTrack(0, track_idx)
2547
+ if not track then
2548
+ errors[#errors + 1] = "Track " .. track_idx .. " not found"
2549
+ else
2550
+ local ok = reaper.TrackFX_SetParam(track, fx_idx, param_idx, value)
2551
+ if not ok then
2552
+ errors[#errors + 1] = "Failed to set track " .. track_idx .. " fx " .. fx_idx .. " param " .. param_idx
2553
+ else
2554
+ updated = updated + 1
2555
+ end
2556
+ end
2557
+ end
2558
+ end
2559
+
2560
+ reaper.Undo_EndBlock("MCP: Batch set " .. updated .. " FX parameters", -1)
2561
+
2562
+ local result = { success = true, updated = updated, total = #updates }
2563
+ if #errors > 0 then result.errors = errors end
2564
+ return result
2565
+ end
2566
+
2355
2567
  -- =============================================================================
2356
2568
  -- Selection & Navigation
2357
2569
  -- =============================================================================
@@ -2671,8 +2883,9 @@ function handlers.create_track_envelope(params)
2671
2883
  return nil, "Unknown envelope name: " .. params.envelopeName .. ". Use Volume, Pan, Mute, Width, or Trim Volume"
2672
2884
  end
2673
2885
  -- Get track chunk, insert envelope chunk if missing
2886
+ -- Use anchored pattern "<VOLENV\n" to avoid matching VOLENV2 (Trim Volume)
2674
2887
  local _, chunk = reaper.GetTrackStateChunk(track, "", false)
2675
- if not chunk:find(chunk_key) then
2888
+ if not chunk:find("<" .. chunk_key .. "\n") then
2676
2889
  -- Insert a minimal envelope chunk before the closing >
2677
2890
  local env_chunk = "\n<" .. chunk_key .. "\nACT 1 -1\nVIS 1 1 1\nLANEHEIGHT 0 0\nARM 0\nDEFSHAPE 0 -1 -1\n>\n"
2678
2891
  -- Use position capture to find the last ">" (closing the <TRACK block).
@@ -2684,8 +2897,8 @@ function handlers.create_track_envelope(params)
2684
2897
  reaper.SetTrackStateChunk(track, chunk, false)
2685
2898
  else
2686
2899
  -- Envelope exists in chunk but may be hidden; make it visible
2687
- chunk = chunk:gsub("(" .. chunk_key .. "[^\n]*\n)ACT 0", "%1ACT 1")
2688
- chunk = chunk:gsub("(" .. chunk_key .. "[^\n]*\n[^\n]*\n)VIS 0", "%1VIS 1")
2900
+ chunk = chunk:gsub("(<" .. chunk_key .. "[^\n]*\n)ACT 0", "%1ACT 1")
2901
+ chunk = chunk:gsub("(<" .. chunk_key .. "[^\n]*\n[^\n]*\n)VIS 0", "%1VIS 1")
2689
2902
  reaper.SetTrackStateChunk(track, chunk, false)
2690
2903
  end
2691
2904
  env = reaper.GetTrackEnvelopeByName(track, params.envelopeName)
@@ -58,7 +58,6 @@ slider1:window_ms=300<50,2000,10>Window (ms)
58
58
  gmem[3] = -150;
59
59
  );
60
60
 
61
- local(l, r, m, s);
62
61
  l = spl0;
63
62
  r = spl1;
64
63
 
@@ -66,7 +65,6 @@ slider1:window_ms=300<50,2000,10>Window (ms)
66
65
  m = (l + r) * 0.5;
67
66
  s = (l - r) * 0.5;
68
67
 
69
- local(ll, rr, lr, mm, ss);
70
68
  ll = l * l;
71
69
  rr = r * r;
72
70
  lr = l * r;
@@ -99,8 +97,6 @@ slider1:window_ms=300<50,2000,10>Window (ms)
99
97
 
100
98
  // Update gmem every 512 samples (~86 Hz at 44.1kHz)
101
99
  (buf_pos % 512) == 0 ? (
102
- local(denom, corr, mid_rms, side_rms, mid_db, side_db, width);
103
-
104
100
  // Correlation: sum(L*R) / sqrt(sum(L*L) * sum(R*R))
105
101
  denom = sqrt(sum_ll * sum_rr);
106
102
  denom > 0.0000001 ? (
@@ -49,7 +49,6 @@ slider2:peak_hold_ms=1000<100,10000,100>Peak Hold (ms)
49
49
  );
50
50
 
51
51
  // Mono mix for metering
52
- local(mono, sq, peak_abs);
53
52
  mono = (spl0 + spl1) * 0.5;
54
53
  sq = mono * mono;
55
54
  peak_abs = abs(mono);
@@ -76,8 +75,6 @@ slider2:peak_hold_ms=1000<100,10000,100>Peak Hold (ms)
76
75
 
77
76
  // Update gmem every 512 samples
78
77
  (buf_pos % 512) == 0 ? (
79
- local(rms_val, rms_db, peak_db, crest_db);
80
-
81
78
  rms_val = sqrt(sum_sq / buf_size);
82
79
 
83
80
  rms_val > 0.000001 ? (
@@ -21,29 +21,18 @@ slider2:track_slot=0<0,127,1>Track Slot
21
21
  // K-weighting filter state (two biquad stages per channel)
22
22
  // Stage 1: High-shelf pre-filter (+4 dB at 1681 Hz)
23
23
  // Stage 2: High-pass RLB filter (100 Hz, -12 dB/oct)
24
-
25
- // Biquad state: [b0, b1, b2, a1, a2, x1, x2, y1, y2] per stage per channel
26
- // We store state variables in flat arrays
27
24
  hs_x1l = hs_x2l = hs_y1l = hs_y2l = 0;
28
25
  hs_x1r = hs_x2r = hs_y1r = hs_y2r = 0;
29
26
  hp_x1l = hp_x2l = hp_y1l = hp_y2l = 0;
30
27
  hp_x1r = hp_x2r = hp_y1r = hp_y2r = 0;
31
28
 
32
- // High-shelf filter coefficients (pre-computed for 48kHz; recomputed @slider)
33
- // Using bilinear transform of analog prototype per BS.1770-4 Annex 1
29
+ // High-shelf filter coefficients (recomputed on init and sample rate change)
34
30
  hs_b0 = hs_b1 = hs_b2 = hs_a1 = hs_a2 = 0;
35
31
  hp_b0 = hp_b1 = hp_b2 = hp_a1 = hp_a2 = 0;
36
32
 
37
- // Circular buffer for loudness blocks (sliding windows)
38
- // 400ms block = momentary, 3s = short-term
39
- // BS.1770 uses 100ms overlapping blocks (75% overlap)
40
- // We approximate with per-sample accumulation into sliding windows
41
-
42
- // Ring buffer for per-sample squared weighted values
43
- // momentary: 400ms window
44
- // short-term: 3s window
45
- // We store up to 3s of samples in a circular buffer
46
- buf_size = 0; // computed at runtime from srate
33
+ // Ring buffer for per-sample squared weighted values (up to 3s)
34
+ // momentary: 400ms window, short-term: 3s window
35
+ buf_size = 0;
47
36
  buf_l = 65536; // start address for left channel ring buffer
48
37
  buf_r = 131072; // start address for right channel ring buffer
49
38
  buf_pos = 0;
@@ -63,13 +52,11 @@ slider2:track_slot=0<0,127,1>Track Slot
63
52
  // Measurement duration
64
53
  sample_count = 0;
65
54
 
66
- // Flags
67
55
  needs_init = 1;
68
56
 
69
57
  function compute_kweight_filters() (
70
58
  // High-shelf pre-filter: +4 dB shelf at f0 = 1681.974 Hz
71
59
  // From BS.1770-4, Annex 1, Table 1
72
- local(db, K, Vh, Vb, a0);
73
60
  db = 3.99984385397; // ~4 dB
74
61
  K = tan($pi * 1681.974 / srate);
75
62
  Vh = exp(db / 20 * log(10));
@@ -82,7 +69,6 @@ slider2:track_slot=0<0,127,1>Track Slot
82
69
  hs_a2 = (1 - K/0.7071067811865476 + K*K) / a0;
83
70
 
84
71
  // High-pass filter: 38.13547 Hz, Q = 0.5003270373238773
85
- local(K2, a0);
86
72
  K2 = tan($pi * 38.13547 / srate);
87
73
  a0 = K2*K2 + K2/0.5003270373238773 + 1;
88
74
  hp_b0 = 1 / a0;
@@ -93,7 +79,6 @@ slider2:track_slot=0<0,127,1>Track Slot
93
79
  );
94
80
 
95
81
  function reset_measurement() (
96
- local(base);
97
82
  base = floor(slider2 + 0.5) * 8;
98
83
  buf_pos = 0;
99
84
  momentary_sum_l = 0;
@@ -121,7 +106,7 @@ slider2:track_slot=0<0,127,1>Track Slot
121
106
  );
122
107
 
123
108
  @slider
124
- // Re-compute filter coefficients (in case sample rate has changed)
109
+ // Re-compute filter coefficients (handles sample rate changes)
125
110
  needs_init = 1;
126
111
 
127
112
  // Handle reset button
@@ -142,7 +127,6 @@ slider2:track_slot=0<0,127,1>Track Slot
142
127
  );
143
128
 
144
129
  // --- K-weighting: stage 1 (high-shelf) ---
145
- local(wl, wr, kl, kr);
146
130
  wl = spl0;
147
131
  wr = spl1;
148
132
 
@@ -155,7 +139,6 @@ slider2:track_slot=0<0,127,1>Track Slot
155
139
  hs_x2r = hs_x1r; hs_x1r = wr; hs_y2r = hs_y1r; hs_y1r = kr;
156
140
 
157
141
  // --- K-weighting: stage 2 (high-pass) ---
158
- local(fl, fr);
159
142
  fl = hp_b0 * kl + hp_b1 * hp_x1l + hp_b2 * hp_x2l - hp_a1 * hp_y1l - hp_a2 * hp_y2l;
160
143
  hp_x2l = hp_x1l; hp_x1l = kl; hp_y2l = hp_y1l; hp_y1l = fl;
161
144
 
@@ -163,13 +146,10 @@ slider2:track_slot=0<0,127,1>Track Slot
163
146
  hp_x2r = hp_x1r; hp_x1r = kr; hp_y2r = hp_y1r; hp_y1r = fr;
164
147
 
165
148
  // Squared weighted samples
166
- local(sq_l, sq_r);
167
149
  sq_l = fl * fl;
168
150
  sq_r = fr * fr;
169
151
 
170
152
  // --- Ring buffer update ---
171
- // Subtract outgoing samples from running sums
172
- local(old_l, old_r);
173
153
  old_l = buf_l[buf_pos];
174
154
  old_r = buf_r[buf_pos];
175
155
 
@@ -177,12 +157,7 @@ slider2:track_slot=0<0,127,1>Track Slot
177
157
  shortterm_sum_l -= old_l;
178
158
  shortterm_sum_r -= old_r;
179
159
 
180
- // Momentary window: 400ms = srate * 0.4 samples back
181
- // We subtract from momentary only when the sample leaving is within 400ms window
182
- // Simplified: maintain separate momentary accumulators via separate pointers
183
- // For efficiency, use a single ring buffer and recompute momentary from partial sum
184
- // Here we track momentary with a second set of pointers (400ms lag)
185
- local(mom_pos);
160
+ // Momentary window: 400ms lag pointer
186
161
  mom_pos = buf_pos - floor(srate * 0.4);
187
162
  mom_pos < 0 ? mom_pos += buf_size;
188
163
  momentary_sum_l -= buf_l[mom_pos];
@@ -203,18 +178,12 @@ slider2:track_slot=0<0,127,1>Track Slot
203
178
  buf_pos >= buf_size ? buf_pos = 0;
204
179
 
205
180
  // --- Integrated loudness (gated per BS.1770) ---
206
- // Absolute gate: only include 400ms blocks above -70 LUFS
207
- // Simplified: accumulate ungated for speed, apply gate check periodically
181
+ // Simplified: accumulate ungated, apply gate check periodically
208
182
  integrated_sum += sq_l + sq_r;
209
183
  integrated_count += 1;
210
184
 
211
185
  // --- True peak: 4x linear interpolation oversampling ---
212
- // Insert 3 interpolated samples between each real sample using linear interpolation
213
- local(prev_l, prev_r, frac, tp_l, tp_r);
214
-
215
- // We use the previous sample stored in a local state var
216
- // Stored in memory block above buf_r to avoid conflict
217
- // prev_l is at buf_r + buf_size, prev_r is at buf_r + buf_size + 1
186
+ // Previous sample stored above buf_r to avoid conflict
218
187
  prev_l = buf_r[buf_size];
219
188
  prev_r = buf_r[buf_size + 1];
220
189
 
@@ -224,7 +193,6 @@ slider2:track_slot=0<0,127,1>Track Slot
224
193
  // Check interpolated peaks at 1/4, 2/4, 3/4 offsets
225
194
  frac = 0.25;
226
195
  loop(3,
227
- local(interp_l, interp_r);
228
196
  interp_l = abs(prev_l + frac * (spl0 - prev_l));
229
197
  interp_r = abs(prev_r + frac * (spl1 - prev_r));
230
198
  interp_l > tp_l ? tp_l = interp_l;
@@ -243,9 +211,6 @@ slider2:track_slot=0<0,127,1>Track Slot
243
211
 
244
212
  // --- Update gmem every 4096 samples (approx 10x/second at 44.1kHz) ---
245
213
  (sample_count % 4096) == 0 ? (
246
- local(shortterm_mean, momentary_mean, integrated_lufs, shortterm_lufs, momentary_lufs);
247
- local(mom_samples, base);
248
-
249
214
  base = floor(slider2 + 0.5) * 8;
250
215
 
251
216
  mom_samples = floor(srate * 0.4);
@@ -255,8 +220,7 @@ slider2:track_slot=0<0,127,1>Track Slot
255
220
  shortterm_mean = (shortterm_sum_l + shortterm_sum_r) / (buf_size * 2);
256
221
  momentary_mean = (momentary_sum_l + momentary_sum_r) / (mom_samples * 2);
257
222
 
258
- // Integrated mean square (two channels: L + R sum, divided by count*2)
259
- local(integrated_mean);
223
+ // Integrated mean square (two channels)
260
224
  integrated_mean = integrated_count > 0 ? (integrated_sum / (integrated_count * 2)) : 0;
261
225
 
262
226
  // LUFS = -0.691 + 10 * log10(mean_square)
@@ -274,7 +238,6 @@ slider2:track_slot=0<0,127,1>Track Slot
274
238
 
275
239
  integrated_mean > 0 ? (
276
240
  // Apply absolute gate at -70 LUFS
277
- local(ungated_lufs);
278
241
  ungated_lufs = -0.691 + 10 * log10(integrated_mean);
279
242
  ungated_lufs > -70 ? (
280
243
  integrated_lufs = ungated_lufs;