@mthines/reaper-mcp 0.19.0 → 0.70.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +19 -570
  2. package/bridge-ui/mcp_bridge.html +129 -0
  3. package/bridge-ui/mcp_bridge.webmanifest +16 -0
  4. package/bridge-ui/mcp_bridge_icon_192.png +0 -0
  5. package/bridge-ui/mcp_bridge_icon_512.png +0 -0
  6. package/bridge-ui/mcp_bridge_sw.js +35 -0
  7. package/claude/agents/plugin-researcher.md +188 -0
  8. package/claude/knowledge/reference/aesthetics-scoring.md +48 -0
  9. package/{knowledge → claude/knowledge}/reference/common-mistakes.md +1 -1
  10. package/claude/knowledge/reference/lorekit-setup.md +36 -0
  11. package/claude/knowledge/reference/memory-protocol.md +377 -0
  12. package/{knowledge → claude/knowledge}/reference/metering.md +5 -5
  13. package/claude/knowledge/reference/perceived-loudness.md +182 -0
  14. package/{knowledge → claude/knowledge}/workflows/editing.md +4 -0
  15. package/{knowledge → claude/knowledge}/workflows/gain-staging.md +21 -23
  16. package/{knowledge → claude/knowledge}/workflows/learn-plugin.md +15 -48
  17. package/{knowledge → claude/knowledge}/workflows/master-bus.md +13 -1
  18. package/claude/knowledge/workflows/section-levels.md +138 -0
  19. package/{knowledge → claude/knowledge}/workflows/session-prep.md +4 -0
  20. package/claude/knowledge/workflows/transient-lighting.md +52 -0
  21. package/claude/skills/critique/SKILL.md +131 -0
  22. package/claude/skills/editing/SKILL.md +281 -0
  23. package/claude/skills/learn-plugin/SKILL.md +210 -0
  24. package/claude/skills/mastering/SKILL.md +151 -0
  25. package/claude/skills/mix-memory/SKILL.md +140 -0
  26. package/claude/skills/mixer/SKILL.md +253 -0
  27. package/claude/skills/new-extension/SKILL.md +324 -0
  28. package/main.js +7843 -1940
  29. package/package.json +17 -15
  30. package/reaper/CLAUDE.md +22 -4
  31. package/reaper/mcp_actions_lib.lua +375 -0
  32. package/reaper/mcp_analyzer.jsfx +27 -12
  33. package/reaper/mcp_arrange_lib.lua +949 -0
  34. package/reaper/mcp_bridge.lua +2321 -758
  35. package/reaper/mcp_edit_views_lib.lua +1378 -0
  36. package/reaper/mcp_envelopes_lib.lua +748 -0
  37. package/reaper/mcp_extensions_lib.lua +55 -0
  38. package/reaper/mcp_fxstate_lib.lua +144 -0
  39. package/reaper/mcp_gain_trace.jsfx +126 -0
  40. package/reaper/mcp_items_lib.lua +877 -0
  41. package/reaper/mcp_lufs_meter.jsfx +16 -13
  42. package/reaper/mcp_midi_lib.lua +555 -0
  43. package/reaper/mcp_mixing_lib.lua +1021 -0
  44. package/reaper/mcp_plan_lib.lua +801 -0
  45. package/reaper/mcp_prefs_lib.lua +127 -0
  46. package/reaper/mcp_project_lib.lua +1088 -0
  47. package/reaper/mcp_recording_lib.lua +595 -0
  48. package/reaper/mcp_render_lib.lua +492 -0
  49. package/reaper/mcp_snapshot_lib.lua +547 -120
  50. package/reaper/mcp_snapshot_next.lua +10 -4
  51. package/reaper/mcp_snapshot_prev.lua +11 -4
  52. package/reaper/mcp_snapshot_quick_save.lua +7 -32
  53. package/reaper/mcp_snapshots_ui_lib.lua +258 -0
  54. package/reaper/mcp_tracks_lib.lua +409 -0
  55. package/sidecar/analysis.py +661 -0
  56. package/sidecar/chords.py +96 -0
  57. package/sidecar/mixcheck.py +295 -0
  58. package/sidecar/requirements-chords.txt +6 -0
  59. package/sidecar/requirements-separation.txt +3 -0
  60. package/sidecar/requirements.txt +17 -0
  61. package/sidecar/separation.py +142 -0
  62. package/sidecar/server.py +622 -0
  63. package/sidecar/tests/test_analysis.py +249 -0
  64. package/sidecar/tests/test_chords.py +117 -0
  65. package/sidecar/tests/test_mixcheck.py +163 -0
  66. package/sidecar/tests/test_separation.py +73 -0
  67. package/sidecar/tests/test_server.py +464 -0
  68. package/sidecar/tests/test_verify.py +243 -0
  69. package/sidecar/tests/test_voice_render.py +268 -0
  70. package/sidecar/tests/test_voice_sketch.py +140 -0
  71. package/sidecar/verify.py +224 -0
  72. package/sidecar/voice_render.py +295 -0
  73. package/sidecar/voice_sketch.py +280 -0
  74. package/claude-agents/critique.md +0 -147
  75. package/claude-agents/editor.md +0 -184
  76. package/claude-agents/levels.md +0 -107
  77. package/claude-agents/mastering.md +0 -129
  78. package/claude-agents/mixer.md +0 -248
  79. package/claude-agents/preflight.md +0 -149
  80. package/claude-agents/producer.md +0 -159
  81. package/claude-agents/setup.md +0 -150
  82. package/claude-agents/stems.md +0 -135
  83. package/claude-rules/architecture.md +0 -39
  84. package/claude-rules/development.md +0 -54
  85. package/claude-rules/lua-bridge.md +0 -50
  86. package/claude-rules/testing.md +0 -42
  87. package/claude-skills/learn-plugin.md +0 -123
  88. package/knowledge/CLAUDE.md +0 -71
  89. package/knowledge/plugins/_template.md +0 -82
  90. package/knowledge/plugins/fabfilter/pro-c-2.md +0 -117
  91. package/knowledge/plugins/fabfilter/pro-l-2.md +0 -95
  92. package/knowledge/plugins/fabfilter/pro-q-3.md +0 -112
  93. package/knowledge/plugins/neural-dsp/helix-native.md +0 -104
  94. package/knowledge/plugins/stock-reaper/js-1175-compressor.md +0 -94
  95. package/knowledge/plugins/stock-reaper/rea-comp.md +0 -100
  96. package/knowledge/plugins/stock-reaper/rea-delay.md +0 -95
  97. package/knowledge/plugins/stock-reaper/rea-eq.md +0 -103
  98. package/knowledge/plugins/stock-reaper/rea-gate.md +0 -99
  99. package/knowledge/plugins/stock-reaper/rea-limit.md +0 -75
  100. package/knowledge/plugins/stock-reaper/rea-verb.md +0 -76
  101. package/knowledge/reference/perceived-loudness.md +0 -122
  102. package/reaper/mcp_snapshot_manager.lua +0 -1001
  103. /package/{knowledge → claude/knowledge}/genres/_template.md +0 -0
  104. /package/{knowledge → claude/knowledge}/genres/electronic.md +0 -0
  105. /package/{knowledge → claude/knowledge}/genres/hip-hop.md +0 -0
  106. /package/{knowledge → claude/knowledge}/genres/metal.md +0 -0
  107. /package/{knowledge → claude/knowledge}/genres/orchestral.md +0 -0
  108. /package/{knowledge → claude/knowledge}/genres/pop.md +0 -0
  109. /package/{knowledge → claude/knowledge}/genres/rock.md +0 -0
  110. /package/{knowledge → claude/knowledge}/reference/compression.md +0 -0
  111. /package/{knowledge → claude/knowledge}/reference/frequencies.md +0 -0
  112. /package/{knowledge → claude/knowledge}/workflows/delivery.md +0 -0
  113. /package/{knowledge → claude/knowledge}/workflows/drum-bus.md +0 -0
  114. /package/{knowledge → claude/knowledge}/workflows/low-end.md +0 -0
  115. /package/{knowledge → claude/knowledge}/workflows/stem-prep.md +0 -0
  116. /package/{knowledge → claude/knowledge}/workflows/vocal-chain.md +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mthines/reaper-mcp",
3
- "version": "0.19.0",
3
+ "version": "0.70.2",
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",
@@ -26,11 +26,26 @@
26
26
  "bin": {
27
27
  "reaper-mcp": "./main.js"
28
28
  },
29
+ "files": [
30
+ "main.js",
31
+ "reaper",
32
+ "sidecar",
33
+ "claude",
34
+ "bridge-ui",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "publishConfig": {
39
+ "access": "public",
40
+ "provenance": true
41
+ },
29
42
  "dependencies": {
30
- "@inquirer/prompts": "^8.3.2",
31
43
  "@modelcontextprotocol/sdk": "^1.12.0",
32
44
  "@opentelemetry/api": "^1.9.0",
45
+ "@opentelemetry/api-logs": "^0.213.0",
33
46
  "@opentelemetry/auto-instrumentations-node": "^0.71.0",
47
+ "@opentelemetry/exporter-metrics-otlp-proto": "^0.213.0",
48
+ "@opentelemetry/exporter-trace-otlp-proto": "^0.213.0",
34
49
  "@opentelemetry/resources": "^2.6.0",
35
50
  "@opentelemetry/sdk-node": "^0.213.0",
36
51
  "@opentelemetry/semantic-conventions": "^1.40.0",
@@ -38,18 +53,5 @@
38
53
  },
39
54
  "engines": {
40
55
  "node": ">=20.0.0"
41
- },
42
- "files": [
43
- "main.js",
44
- "reaper/**",
45
- "knowledge/**",
46
- "claude-rules/**",
47
- "claude-skills/**",
48
- "claude-agents/**",
49
- "README.md",
50
- "LICENSE"
51
- ],
52
- "publishConfig": {
53
- "access": "public"
54
56
  }
55
57
  }
package/reaper/CLAUDE.md CHANGED
@@ -7,8 +7,21 @@ Files installed INTO the REAPER DAW by the `setup` command. These run inside REA
7
7
  | File | Language | Purpose |
8
8
  |------|----------|---------|
9
9
  | `mcp_bridge.lua` | Lua | Persistent bridge: polls for JSON commands, executes ReaScript API, writes responses |
10
- | `mcp_snapshot_manager.lua` | Lua | Standalone snapshot manager GUI (gfx-based, save/restore/delete snapshots) |
10
+ | `mcp_tracks_lib.lua` | Lua | Track structure and routing |
11
+ | `mcp_plan_lib.lua` | Lua | `run_plan` executor, JSON decoder, chunk helpers |
12
+ | `mcp_actions_lib.lua` | Lua | Action search/run proxy, selection setters |
13
+ | `mcp_project_lib.lua` | Lua | Project ops, render settings, tempo map, markers |
14
+ | `mcp_items_lib.lua` | Lua | Items, takes, take FX, FX chains, track templates, stem tracks (`insert_stems`) |
15
+ | `mcp_recording_lib.lua` | Lua | Record inputs, monitoring and modes, punch, fixed lanes, comping |
16
+ | `mcp_arrange_lib.lua` | Lua | Copy items, insert/remove time, copy a section, normalize/reverse/glue |
17
+ | `mcp_envelopes_lib.lua` | Lua | Envelopes, points (dB through the envelope's scaling), pooled automation items |
18
+ | `mcp_mixing_lib.lua` | Lua | Automation mode, FX modulation and pins, monitoring FX, freeze, apply FX, stretch markers and audio quantize |
19
+ | `mcp_render_lib.lua` | Lua | Track renders for the analysis tools: cache, multi-track stem pass, macOS focus hand-back |
20
+ | `mcp_midi_lib.lua` | Lua | Quantize, transform, MIDI file import, pitch-class profile for detect_key, bar/beat grid + per-segment profiles for detect_chords |
21
+ | `mcp_edit_views_lib.lua` | Lua | Status page results and before/after edit views |
11
22
  | `mcp_snapshot_lib.lua` | Lua | Shared library for snapshot action scripts (path helpers, JSON, restore logic) |
23
+ | `mcp_prefs_lib.lua` | Lua | Keeps the page's read-only (lk_ro_) LoreKit token in `~/.config/reaper-mcp/lorekit.json` (mode 600) so every way of opening the page shares them; bridge holds it in `ext.prefs` (the bridge is at Lua's 200-locals limit: add no top-level local) |
24
+ | `mcp_snapshots_ui_lib.lua` | Lua | Status page Snapshots view: lists the folder, runs page restore/save requests through the bridge's dispatcher |
12
25
  | `mcp_snapshot_next.lua` | Lua | Action: restore next snapshot (bind to key for A/B testing) |
13
26
  | `mcp_snapshot_prev.lua` | Lua | Action: restore previous snapshot (bind to key for A/B testing) |
14
27
  | `mcp_snapshot_quick_save.lua` | Lua | Action: save snapshot with auto-name, no dialog |
@@ -16,6 +29,7 @@ Files installed INTO the REAPER DAW by the `setup` command. These run inside REA
16
29
  | `mcp_lufs_meter.jsfx` | JSFX/EEL2 | LUFS loudness metering |
17
30
  | `mcp_correlation_meter.jsfx` | JSFX/EEL2 | Stereo correlation and width analysis |
18
31
  | `mcp_crest_factor.jsfx` | JSFX/EEL2 | Crest factor (peak-to-RMS) measurement |
32
+ | `mcp_gain_trace.jsfx` | JSFX/EEL2 | Pre/post gain trace for pumping/ducking: pair it around a chain (Mode pre/post, same Slot), read p5/p1/std after playback. No `1e9`-style literals (EEL2 rejects them; `jsfx-lint.test.ts` checks) |
19
33
  | `install.sh` | Shell | Manual install helper |
20
34
 
21
35
  ## Lua Bridge (`mcp_bridge.lua`)
@@ -26,6 +40,7 @@ Files installed INTO the REAPER DAW by the `setup` command. These run inside REA
26
40
  3. Dispatches to handler function in the `handlers` table
27
41
  4. Writes `response_{uuid}.json` with results
28
42
  5. Writes `heartbeat.json` every 1s for liveness detection
43
+ 6. Publishes a status feed to ExtState `MCPBridge/status` for the web status page (`apps/bridge-ui`)
29
44
 
30
45
  ### Adding a Handler
31
46
  ```lua
@@ -54,9 +69,12 @@ The command type string must exactly match `CommandType` in `libs/protocol/src/c
54
69
 
55
70
  ## Testing
56
71
 
57
- - **No automated tests possible** — REAPER's Lua/JSFX environment cannot be unit tested outside REAPER
58
- - Test manually: install bridge, run MCP Inspector, exercise commands
59
- - Server-side tests mock `sendCommand()` in `bridge.ts`
72
+ - **Lua runs in tests through wasmoon** (Lua 5.4 in WebAssembly) against a fake `reaper` API: the `*_lib.lua`
73
+ files are pure (they take the API as an argument) and are tested in `*-lib.test.ts`; the whole bridge runs in
74
+ `bridge-*.test.ts`. Put new logic in a lib, not in `mcp_bridge.lua` (its main chunk is at Lua's 200-locals limit)
75
+ - Check REAPER's real behaviour live before trusting the fake: some calls ignore arguments without an error
76
+ (e.g. `SetEnvelopePointEx` with a flagged automation item index), and the fake should model what you saw
77
+ - Server-side tests stub `sendCommand()` in `bridge.ts`
60
78
 
61
79
  ## Installation
62
80
 
@@ -0,0 +1,375 @@
1
+ -- mcp_actions_lib.lua — REAPER action proxy and selection setters.
2
+ --
3
+ -- Pure logic: every function takes the ReaScript API as `api`, so it runs
4
+ -- under a fake API in unit tests. Loaded by mcp_bridge.lua with dofile and
5
+ -- passed to mcp_plan_lib.lua (run_plan ops) through its ctx.
6
+ --
7
+ -- Command ids of custom actions, scripts and extension actions are assigned
8
+ -- per session; the stable handle is the named id ("_aee8…", "_SWS_…"), which
9
+ -- every result carries.
10
+
11
+ local M = {}
12
+
13
+ -- Sections with a command entry point. Inline MIDI editor and media explorer
14
+ -- have none, so they are not offered.
15
+ M.SECTIONS = { main = 0, midi_editor = 32060, midi_event_list = 32061 }
16
+
17
+ -- App-level actions that would end, replace or switch away from the project
18
+ -- the agent works in. Refused even with allowDialog. Lua patterns matched
19
+ -- against the lowercased name; REAPER lists some with a "File:" prefix and
20
+ -- some without ("Close current project tab", "New project tab").
21
+ local DENY_PATTERNS = {
22
+ "^file: quit", "^quit reaper",
23
+ "^file: close", "^close project", "^close all projects", "^close current project",
24
+ "^file: new project", "^new project$", "^new project %(", "^new project tab",
25
+ "^file: open project", "^open project$", "^open project%.%.%.", "^open project in new tab",
26
+ "^project tabs: close", "^project tabs: switch to",
27
+ -- Anywhere in the name: SWS and item actions that open or switch to another project.
28
+ "switch to .*project tab", "send next action to .*project tab",
29
+ "new project tab", "in new tab", "%(new tab",
30
+ "open project,", "open project %(", "open projects from", "open last project",
31
+ "open related project", "open associated", "open reaper project",
32
+ }
33
+
34
+ local function section_id(name)
35
+ if name == nil or name == "" then return 0 end
36
+ local id = M.SECTIONS[name]
37
+ if not id then error("unknown section '" .. tostring(name) .. "' (use main, midi_editor or midi_event_list)", 0) end
38
+ return id
39
+ end
40
+
41
+ local function handle(api, sec)
42
+ return api.SectionFromUniqueID and api.SectionFromUniqueID(sec) or sec
43
+ end
44
+
45
+ local function enumerate(api, sec, visit)
46
+ local h = handle(api, sec)
47
+ local i = 0
48
+ while true do
49
+ local id, name = api.kbd_enumerateActions(h, i)
50
+ if not id or id <= 0 then return end
51
+ if visit(id, name or "") then return end
52
+ i = i + 1
53
+ end
54
+ end
55
+
56
+ local function named_id(api, id)
57
+ local s = api.ReverseNamedCommandLookup(id)
58
+ if s and s ~= "" then return s:sub(1, 1) == "_" and s or "_" .. s end
59
+ return nil
60
+ end
61
+
62
+ local function trim(s) return (s:gsub("^%s+", ""):gsub("%s+$", "")) end
63
+
64
+ function M.is_dialog(name)
65
+ name = trim(name)
66
+ return name:sub(-3) == "..." or name:sub(-3) == "\226\128\166" -- "…"
67
+ end
68
+
69
+ function M.deny_reason(name)
70
+ local lower = trim(name):lower()
71
+ for _, p in ipairs(DENY_PATTERNS) do
72
+ if lower:find(p) then
73
+ return "'" .. trim(name) .. "' would close or replace the project the agent is working in"
74
+ end
75
+ end
76
+ return nil
77
+ end
78
+
79
+ local function unsaved_project(api)
80
+ if not api.EnumProjects then return false end
81
+ local _, path = api.EnumProjects(-1, "")
82
+ return path == nil or path == ""
83
+ end
84
+
85
+ --- Whether running `name` opens a modal dialog: a "..." name, or any Save
86
+ --- (project, all projects, new version) on a project that was never saved,
87
+ --- where REAPER asks where to save it.
88
+ function M.opens_dialog(api, name)
89
+ if M.is_dialog(name) then return true end
90
+ return trim(name):lower():sub(1, 10) == "file: save" and unsaved_project(api)
91
+ end
92
+
93
+ local function step_id(api, s)
94
+ local n = tonumber(s)
95
+ if n then return n end
96
+ local id = api.NamedCommandLookup(s:sub(1, 1) == "_" and s or "_" .. s)
97
+ return id and id > 0 and id or nil
98
+ end
99
+
100
+ --- Check an action and, for a Custom: macro, every step it runs (nested
101
+ --- macros too), since running the macro runs them all. Returns the deny
102
+ --- reason, or nil, and a label of what opens a dialog, or nil.
103
+ local function inspect(api, id, name, sec, macros, seen)
104
+ local denied = M.deny_reason(name)
105
+ if denied then return denied, nil end
106
+ local dialog = M.opens_dialog(api, name) and ("'" .. trim(name) .. "'") or nil
107
+ local nid = macros and named_id(api, id)
108
+ local macro = nid and macros[nid]
109
+ if not macro or seen[nid] then return nil, dialog end
110
+ seen[nid] = true
111
+ local h = handle(api, sec)
112
+ for k, s in ipairs(macro.steps) do
113
+ local sid = step_id(api, s)
114
+ local sname = sid and api.kbd_getTextFromCmd(sid, h)
115
+ if sname and sname ~= "" then
116
+ local d, g = inspect(api, sid, sname, sec, macros, seen)
117
+ if d then return "macro step " .. k .. ": " .. d, nil end
118
+ dialog = dialog or (g and ("macro step " .. k .. " " .. g))
119
+ end
120
+ end
121
+ return nil, dialog
122
+ end
123
+
124
+ local function shortcuts(api, sec, id)
125
+ if not api.CountActionShortcuts then return {} end
126
+ local h = handle(api, sec)
127
+ local out = {}
128
+ for k = 0, api.CountActionShortcuts(h, id) - 1 do
129
+ local ok, desc = api.GetActionShortcutDesc(h, id, k, "")
130
+ if ok and desc and desc ~= "" then out[#out + 1] = desc end
131
+ end
132
+ return out
133
+ end
134
+
135
+ local function section_name(sec)
136
+ for name, id in pairs(M.SECTIONS) do if id == sec then return name end end
137
+ return tostring(sec)
138
+ end
139
+
140
+ --- `macros` (from parse_kb) is optional; with it a macro is flagged by its steps.
141
+ function M.describe(api, sec, id, name, macros)
142
+ local denied, dialog = inspect(api, id, name, sec, macros, {})
143
+ return {
144
+ id = id,
145
+ namedId = named_id(api, id),
146
+ name = name,
147
+ section = section_name(sec),
148
+ toggle = api.GetToggleCommandStateEx(sec, id), -- -1: not a toggle
149
+ shortcuts = shortcuts(api, sec, id),
150
+ opensDialog = dialog ~= nil,
151
+ denied = denied,
152
+ }
153
+ end
154
+
155
+ --- search_actions: every query word must appear (case-insensitive) in the
156
+ --- name or named id. Returns at most `limit` actions plus the total count.
157
+ function M.search(api, params, kb_text)
158
+ local sec = section_id(params.section)
159
+ local macros = M.parse_kb(kb_text)
160
+ local limit = math.max(1, math.min(tonumber(params.limit) or 20, 100))
161
+ local words = {}
162
+ for w in tostring(params.query or ""):lower():gmatch("%S+") do words[#words + 1] = w end
163
+
164
+ local found, total = {}, 0
165
+ enumerate(api, sec, function(id, name)
166
+ local hay = name:lower() .. " " .. (named_id(api, id) or ""):lower()
167
+ for _, w in ipairs(words) do
168
+ if not hay:find(w, 1, true) then return false end
169
+ end
170
+ total = total + 1
171
+ if #found < limit then found[#found + 1] = M.describe(api, sec, id, name, macros) end
172
+ return false
173
+ end)
174
+ return { section = section_name(sec), query = params.query or "", total = total, returned = #found, actions = found }
175
+ end
176
+
177
+ --- Resolve an action reference to (id, name). Accepts a command id (number
178
+ --- or digit string), a named id with or without the leading underscore, or
179
+ --- an exact action name (case-insensitive, must be unique).
180
+ function M.resolve(api, ref, sec)
181
+ sec = sec or 0
182
+ local h = handle(api, sec)
183
+ local function by_id(id)
184
+ local name = api.kbd_getTextFromCmd(id, h)
185
+ if not name or name == "" then return nil, "no action with id " .. id .. " in section " .. section_name(sec) end
186
+ return id, name
187
+ end
188
+
189
+ if type(ref) == "number" then return by_id(math.floor(ref)) end
190
+ if type(ref) ~= "string" or trim(ref) == "" then return nil, "action reference missing" end
191
+ ref = trim(ref)
192
+ if ref:match("^%d+$") then return by_id(tonumber(ref)) end
193
+
194
+ if not ref:find("%s") then
195
+ local id = api.NamedCommandLookup(ref:sub(1, 1) == "_" and ref or "_" .. ref)
196
+ if id and id > 0 then return by_id(id) end
197
+ if ref:sub(1, 1) == "_" then return nil, "no action with named id " .. ref end
198
+ end
199
+
200
+ local want, hits = ref:lower(), {}
201
+ enumerate(api, sec, function(id, name)
202
+ if trim(name):lower() == want then hits[#hits + 1] = { id = id, name = name } end
203
+ return false
204
+ end)
205
+ if #hits == 1 then return hits[1].id, hits[1].name end
206
+ if #hits > 1 then
207
+ local ids = {}
208
+ for k = 1, math.min(#hits, 4) do ids[k] = tostring(hits[k].id) end
209
+ return nil, "action name '" .. ref .. "' is ambiguous (ids " .. table.concat(ids, ", ") .. ") — use an id"
210
+ end
211
+ return nil, "no action named '" .. ref .. "' — use search_actions to find it"
212
+ end
213
+
214
+ --- Why an action must not run now, or nil when it may. Pass `macros` (from
215
+ --- parse_kb) so a Custom: macro is judged by the steps it runs.
216
+ function M.check_runnable(api, id, name, sec, allow_dialog, macros)
217
+ local denied, dialog = inspect(api, id, name, sec, macros, {})
218
+ if denied then return "refused: " .. denied end
219
+ if dialog and not allow_dialog then
220
+ return "refused: " .. dialog .. " opens a dialog, which blocks REAPER (and this bridge) until a person closes it — pass allowDialog only when someone is at the screen"
221
+ end
222
+ if sec ~= 0 and not api.MIDIEditor_GetActive() then
223
+ return "'" .. trim(name) .. "' is a MIDI editor action, but no MIDI editor is open"
224
+ end
225
+ return nil
226
+ end
227
+
228
+ --- Run a resolved, checked action. No undo block of its own (callers own it).
229
+ function M.exec(api, id, sec)
230
+ if sec == 0 then
231
+ api.Main_OnCommandEx(id, 0, 0)
232
+ elseif sec == M.SECTIONS.midi_editor then
233
+ api.MIDIEditor_OnCommand(api.MIDIEditor_GetActive(), id)
234
+ else
235
+ api.MIDIEditor_LastFocused_OnCommand(id, true)
236
+ end
237
+ return api.GetToggleCommandStateEx(sec, id)
238
+ end
239
+
240
+ --- run_action: resolve, refuse unsafe actions, run once in an undo block.
241
+ function M.run(api, params, kb_text)
242
+ local sec = section_id(params.section)
243
+ local id, name = M.resolve(api, params.action, sec)
244
+ if not id then return nil, name end
245
+ local macros = M.parse_kb(kb_text)
246
+ local refused = M.check_runnable(api, id, name, sec, params.allowDialog == true, macros)
247
+ if refused then return nil, refused end
248
+
249
+ api.Undo_BeginBlock()
250
+ local ok, toggle = pcall(M.exec, api, id, sec)
251
+ api.Undo_EndBlock("MCP: " .. trim(name), -1)
252
+ if not ok then return nil, "action '" .. trim(name) .. "' failed: " .. tostring(toggle) end
253
+ if api.UpdateArrange then api.UpdateArrange() end
254
+ local out = M.describe(api, sec, id, name, macros)
255
+ out.ran = true
256
+ out.toggle = toggle
257
+ return out
258
+ end
259
+
260
+ --- Parse reaper-kb.ini: Custom: macros (ACT) and scripts (SCR), keyed by
261
+ --- named id with a leading underscore.
262
+ function M.parse_kb(text)
263
+ local macros, scripts = {}, {}
264
+ for line in (text or ""):gmatch("[^\r\n]+") do
265
+ local sec, id, name, rest = line:match('^ACT%s+%d+%s+(%d+)%s+"([^"]*)"%s+"([^"]*)"%s*(.*)$')
266
+ if sec then
267
+ local steps = {}
268
+ for s in rest:gmatch("%S+") do steps[#steps + 1] = s end
269
+ macros["_" .. id] = { name = name, section = tonumber(sec), steps = steps }
270
+ else
271
+ local ssec, sid, sname, path = line:match('^SCR%s+%d+%s+(%d+)%s+(%S+)%s+"([^"]*)"%s+(.+)$')
272
+ if ssec then scripts["_" .. sid] = { name = sname, section = tonumber(ssec), path = (trim(path):gsub('^"(.*)"$', "%1")) } end
273
+ end
274
+ end
275
+ return macros, scripts
276
+ end
277
+
278
+ --- get_action: details, plus a macro's steps or a script's path.
279
+ function M.get(api, params, kb_text)
280
+ local sec = section_id(params.section)
281
+ local id, name = M.resolve(api, params.action, sec)
282
+ if not id then return nil, name end
283
+ local macros, scripts = M.parse_kb(kb_text)
284
+ local out = M.describe(api, sec, id, name, macros)
285
+ local macro = out.namedId and macros[out.namedId]
286
+ if macro then
287
+ out.kind = "custom"
288
+ out.steps = {}
289
+ for k, s in ipairs(macro.steps) do
290
+ local sid = step_id(api, s)
291
+ local sname = sid and api.kbd_getTextFromCmd(sid, handle(api, sec)) or nil
292
+ out.steps[k] = { action = s, id = sid, name = (sname ~= "" and sname) or "(no such action in this REAPER — the macro step does nothing)" }
293
+ end
294
+ elseif out.namedId and scripts[out.namedId] then
295
+ out.kind = "script"
296
+ out.scriptPath = scripts[out.namedId].path
297
+ else
298
+ out.kind = out.namedId and "extension" or "native"
299
+ end
300
+ return out
301
+ end
302
+
303
+ -- ---------------------------------------------------------------------------
304
+ -- Selection
305
+ -- ---------------------------------------------------------------------------
306
+
307
+ local function selected_tracks(api)
308
+ local out = {}
309
+ local master = api.GetMasterTrack(0)
310
+ if api.IsTrackSelected(master) then out[1] = { index = -1, name = "MASTER" } end
311
+ for i = 0, api.CountTracks(0) - 1 do
312
+ local tr = api.GetTrack(0, i)
313
+ if api.IsTrackSelected(tr) then
314
+ local _, name = api.GetTrackName(tr)
315
+ out[#out + 1] = { index = i, name = name }
316
+ end
317
+ end
318
+ return out
319
+ end
320
+
321
+ --- Select tracks. `tracks` are MediaTracks already resolved by the caller;
322
+ --- mode "replace" (default) clears the selection first, "add" keeps it.
323
+ function M.select_tracks(api, tracks, mode)
324
+ if mode ~= "add" then
325
+ api.SetTrackSelected(api.GetMasterTrack(0), false)
326
+ for i = 0, api.CountTracks(0) - 1 do api.SetTrackSelected(api.GetTrack(0, i), false) end
327
+ end
328
+ for _, tr in ipairs(tracks) do api.SetTrackSelected(tr, true) end
329
+ return selected_tracks(api)
330
+ end
331
+
332
+ --- set_selected_tracks: resolve every ref first (via the injected resolver)
333
+ --- so an unknown track changes nothing.
334
+ function M.set_selected_tracks(api, refs, mode, resolve_track)
335
+ if type(refs) ~= "table" or #refs == 0 then return nil, "tracks array required" end
336
+ local tracks = {}
337
+ for k, ref in ipairs(refs) do
338
+ local res, err = resolve_track(api, ref, {})
339
+ if not res then return nil, "tracks[" .. (k - 1) .. "]: " .. err .. " — selection unchanged" end
340
+ tracks[k] = res.track
341
+ end
342
+ return { selected = M.select_tracks(api, tracks, mode) }
343
+ end
344
+
345
+ --- set_selected_items: items are { trackIndex, itemIndex } like the media
346
+ --- tools. All are resolved before the selection changes.
347
+ function M.set_selected_items(api, items, mode)
348
+ if type(items) ~= "table" or #items == 0 then return nil, "items array required" end
349
+ local found = {}
350
+ for k, it in ipairs(items) do
351
+ local tr = api.GetTrack(0, tonumber(it.trackIndex) or -1)
352
+ local item = tr and api.GetTrackMediaItem(tr, tonumber(it.itemIndex) or -1)
353
+ if not item then
354
+ return nil, "items[" .. (k - 1) .. "]: no item " .. tostring(it.itemIndex) .. " on track " .. tostring(it.trackIndex) .. " — selection unchanged"
355
+ end
356
+ found[k] = item
357
+ end
358
+ if mode ~= "add" then api.SelectAllMediaItems(0, false) end
359
+ for _, item in ipairs(found) do api.SetMediaItemSelected(item, true) end
360
+ if api.UpdateArrange then api.UpdateArrange() end
361
+
362
+ local selected = {}
363
+ for i = 0, api.CountTracks(0) - 1 do
364
+ local tr = api.GetTrack(0, i)
365
+ for j = 0, api.CountTrackMediaItems(tr) - 1 do
366
+ if api.IsMediaItemSelected(api.GetTrackMediaItem(tr, j)) then
367
+ selected[#selected + 1] = { trackIndex = i, itemIndex = j }
368
+ end
369
+ end
370
+ end
371
+ return { selected = selected }
372
+ end
373
+
374
+ M.section_id = section_id
375
+ return M
@@ -3,17 +3,20 @@ desc:MCP Spectrum Analyzer
3
3
  // Writes frequency bin magnitudes (in dB) to shared memory (gmem)
4
4
  // so the Lua bridge can read them and pass to the MCP server.
5
5
  //
6
- // gmem layout:
7
- // gmem[0] = bin count (fft_size / 2)
8
- // gmem[1] = peak dB
9
- // gmem[2] = RMS dB
10
- // gmem[3 .. 3+bin_count-1] = magnitude in dB per frequency bin
6
+ // gmem layout, one block of SLOT_STRIDE values per instance (base = slot * SLOT_STRIDE,
7
+ // slot set by the bridge to trackIndex + 1, master = 0), so analyzers on different
8
+ // tracks never overwrite each other:
9
+ // gmem[base + 0] = bin count (fft_size / 2)
10
+ // gmem[base + 1] = peak dB
11
+ // gmem[base + 2] = RMS dB
12
+ // gmem[base + 3 .. base + 3 + bin_count - 1] = magnitude in dB per frequency bin
11
13
 
12
14
  options:gmem=MCPAnalyzer
13
15
 
14
16
  slider1:fft_size_slider=4096<512,8192,1>FFT Size
15
17
  slider2:window_type=1<0,3,1{Rectangular,Hann,Hamming,Blackman-Harris}>Window Function
16
18
  slider3:smoothing=0.8<0,0.99,0.01>Smoothing
19
+ slider4:gmem_slot=0<0,2000,1>gmem Slot
17
20
 
18
21
  @init
19
22
  // Allocate memory blocks
@@ -62,8 +65,19 @@ slider3:smoothing=0.8<0,0.99,0.01>Smoothing
62
65
  rms_sum = 0;
63
66
  rms_count = 0;
64
67
 
68
+ SLOT_STRIDE = 4100; // 3 header values + up to 4096 bins (fft size 8192)
69
+ base = 0;
70
+
65
71
  @slider
66
- new_fft_size = slider1;
72
+ // A new slot starts empty, so the bridge reports "no data yet" instead of
73
+ // whatever a previous instance left there.
74
+ new_base = floor(gmem_slot + 0.5) * SLOT_STRIDE;
75
+ new_base != base ? (
76
+ base = new_base;
77
+ gmem[base] = 0;
78
+ );
79
+
80
+ new_fft_size = fft_size_slider; // named slider: slider1 stays 0
67
81
  // Snap to valid FFT sizes
68
82
  new_fft_size <= 512 ? new_fft_size = 512 :
69
83
  new_fft_size <= 1024 ? new_fft_size = 1024 :
@@ -106,8 +120,9 @@ slider3:smoothing=0.8<0,0.99,0.01>Smoothing
106
120
  // Copy to work buffer for FFT (real FFT needs 2x size)
107
121
  memcpy(work_buf, fft_buf, fft_size);
108
122
 
109
- // Perform real FFT
123
+ // Perform real FFT; its output is in permuted order, so restore frequency order
110
124
  fft_real(work_buf, fft_size);
125
+ fft_permute(work_buf, fft_size / 2);
111
126
 
112
127
  // Calculate magnitudes and write to gmem with smoothing
113
128
  smooth = smoothing;
@@ -136,25 +151,25 @@ slider3:smoothing=0.8<0,0.99,0.01>Smoothing
136
151
  mag_buf[i] = smooth * mag_buf[i] + inv_smooth * mag_db;
137
152
 
138
153
  // Write to gmem
139
- gmem[3 + i] = mag_buf[i];
154
+ gmem[base + 3 + i] = mag_buf[i];
140
155
 
141
156
  i += 1;
142
157
  );
143
158
 
144
159
  // Write metadata to gmem
145
- gmem[0] = bin_count;
160
+ gmem[base] = bin_count;
146
161
 
147
162
  // Peak dB
148
163
  peak_val < 0.000001 ? peak_val = 0.000001;
149
- gmem[1] = 20.0 * log10(peak_val);
164
+ gmem[base + 1] = 20.0 * log10(peak_val);
150
165
 
151
166
  // RMS dB
152
167
  rms_count > 0 ? (
153
168
  rms_val = sqrt(rms_sum / rms_count);
154
169
  rms_val < 0.000001 ? rms_val = 0.000001;
155
- gmem[2] = 20.0 * log10(rms_val);
170
+ gmem[base + 2] = 20.0 * log10(rms_val);
156
171
  ) : (
157
- gmem[2] = -150.0;
172
+ gmem[base + 2] = -150.0;
158
173
  );
159
174
 
160
175
  // Reset accumulators