@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
@@ -0,0 +1,595 @@
1
+ -- mcp_recording_lib.lua — record setup (arm, input, monitoring, record mode)
2
+ -- and fixed lanes with comping.
3
+ --
4
+ -- Pure logic: every function takes the ReaScript API as `api`, so it runs
5
+ -- under a fake API in unit tests. Loaded by mcp_bridge.lua with dofile.
6
+ -- Every write validates all of its input first: a refused call changes nothing.
7
+
8
+ local M = {}
9
+
10
+ -- Global record mode: one of three toggle actions.
11
+ M.RECORD_MODES = { normal = 40252, time_selection = 40076, selected_items = 40253 }
12
+
13
+ -- I_RECMODE values by name (REAPER 7).
14
+ M.TRACK_MODES = {
15
+ input = 0, stereo_out = 1, none = 2, stereo_out_latency = 3, midi_out = 4,
16
+ mono_out = 5, mono_out_latency = 6, midi_overdub = 7, midi_replace = 8,
17
+ midi_touch_replace = 9, multichannel_out = 10, multichannel_out_latency = 11,
18
+ input_mono = 12, input_stereo = 13, input_multichannel = 14, input_midi = 15,
19
+ midi_latch_replace = 16,
20
+ }
21
+ local MODE_NAME = {}
22
+ for k, v in pairs(M.TRACK_MODES) do MODE_NAME[v] = k end
23
+
24
+ M.MONITOR = { off = 0, on = 1, auto = 2 }
25
+ local MONITOR_NAME = { [0] = "off", [1] = "on", [2] = "auto" }
26
+
27
+ local MAX_TRACKS = 64
28
+ local MAX_LANES = 64
29
+ local MAX_LANE_ITEMS = 100
30
+ local MAX_RANGES = 200
31
+ local MIDI_ALL, MIDI_VKB = 63, 62
32
+
33
+ local function int(v) return math.floor((tonumber(v) or 0) + 0.5) end
34
+ local function round3(v) return v and math.floor(v * 1000 + 0.5) / 1000 or nil end
35
+ local function secs(v) return string.format("%g", round3(v)) end
36
+
37
+ local function track_name(api, tr)
38
+ local _, name = api.GetTrackName(tr)
39
+ return name or ""
40
+ end
41
+
42
+ local function track_index(api, tr)
43
+ return int(api.GetMediaTrackInfo_Value(tr, "IP_TRACKNUMBER")) - 1
44
+ end
45
+
46
+ -- Resolve a user track ref to a real (non-master) track.
47
+ local function resolve(api, ref, resolve_track, what)
48
+ local res, err = resolve_track(api, ref, {})
49
+ if not res then return nil, (what and (what .. ": ") or "") .. err end
50
+ if not res.track or res.track == api.GetMasterTrack(0) then
51
+ return nil, (what and (what .. ": ") or "") .. "the master track cannot record or hold lanes"
52
+ end
53
+ return res.track
54
+ end
55
+
56
+ -- =============================================================================
57
+ -- Record inputs
58
+ -- =============================================================================
59
+
60
+ local function midi_device_name(api, dev)
61
+ if dev == MIDI_ALL then return "all MIDI inputs" end
62
+ if dev == MIDI_VKB then return "virtual MIDI keyboard" end
63
+ local ok, name = api.GetMIDIInputName(dev, "")
64
+ return ok and name or ("MIDI device " .. dev)
65
+ end
66
+
67
+ -- I_RECINPUT → { kind, ... , label }
68
+ function M.decode_input(api, v)
69
+ v = int(v)
70
+ if v < 0 then return { kind = "none", label = "No input" } end
71
+ if v & 4096 ~= 0 then
72
+ local channel = v & 31
73
+ local device = (v >> 5) & 63
74
+ local dev_label = device == MIDI_ALL and "all" or device == MIDI_VKB and "vkb" or device
75
+ return {
76
+ kind = "midi", device = dev_label, channel = channel,
77
+ label = "MIDI: " .. midi_device_name(api, device) .. (channel == 0 and ", all channels" or (", channel " .. channel)),
78
+ }
79
+ end
80
+ local start = (v & 1023) + 1
81
+ local width = (v & 2048 ~= 0) and "multichannel" or (v & 1024 ~= 0) and "stereo" or "mono"
82
+ local label = width == "stereo" and ("In " .. start .. "/" .. (start + 1)) or ("In " .. start)
83
+ if width == "multichannel" then label = label .. " (multichannel)" end
84
+ if api.GetInputChannelName and start <= (api.GetNumAudioInputs() or 0) then
85
+ local name = api.GetInputChannelName(start - 1)
86
+ if name and name ~= "" and width == "mono" then label = label .. " · " .. name end
87
+ end
88
+ return { kind = "audio", channel = start, width = width, label = label }
89
+ end
90
+
91
+ -- { kind = "audio", channel = 1-based, stereo = bool } | { kind = "midi", device = n | "all" | "vkb", channel = 0..16 }
92
+ -- | { kind = "none" } → I_RECINPUT, warnings
93
+ local function encode_input(api, spec, what)
94
+ if type(spec) ~= "table" then return nil, what .. ": input must be an object" end
95
+ if spec.kind == "none" then return -1 end
96
+ if spec.kind == "audio" then
97
+ local ch = tonumber(spec.channel)
98
+ if not ch or ch < 1 or ch ~= math.floor(ch) then return nil, what .. ": input.channel must be a whole number from 1" end
99
+ local stereo = spec.stereo == true
100
+ local count = api.GetNumAudioInputs() or 0
101
+ local last = ch + (stereo and 1 or 0)
102
+ local warn
103
+ if count == 0 then
104
+ warn = what .. ": the audio device reports no inputs (is it open?), so input " .. ch .. " is set unchecked"
105
+ elseif last > count then
106
+ return nil, what .. ": input " .. (stereo and (ch .. "/" .. last) or tostring(ch)) .. " does not exist (the audio device has " .. count .. " inputs)"
107
+ end
108
+ return (int(ch) - 1) | (stereo and 1024 or 0), warn
109
+ end
110
+ if spec.kind == "midi" then
111
+ local dev = spec.device
112
+ if dev == nil or dev == "all" then dev = MIDI_ALL
113
+ elseif dev == "vkb" then dev = MIDI_VKB
114
+ else
115
+ dev = tonumber(dev)
116
+ if not dev or dev < 0 or dev > 61 or dev ~= math.floor(dev) then
117
+ return nil, what .. ": input.device must be a MIDI input index (0-61), \"all\" or \"vkb\""
118
+ end
119
+ local ok = api.GetMIDIInputName(int(dev), "")
120
+ if not ok then return nil, what .. ": MIDI input " .. dev .. " does not exist (get_record_setup lists them)" end
121
+ end
122
+ local channel = tonumber(spec.channel or 0)
123
+ if not channel or channel < 0 or channel > 16 or channel ~= math.floor(channel) then
124
+ return nil, what .. ": input.channel must be 0 (all) to 16 for MIDI"
125
+ end
126
+ return 4096 | int(channel) | (int(dev) << 5)
127
+ end
128
+ return nil, what .. ": input.kind must be audio, midi or none"
129
+ end
130
+
131
+ local function record_state(api, tr)
132
+ local mode = int(api.GetMediaTrackInfo_Value(tr, "I_RECMODE"))
133
+ local monitor = int(api.GetMediaTrackInfo_Value(tr, "I_RECMON"))
134
+ return {
135
+ index = track_index(api, tr),
136
+ name = track_name(api, tr),
137
+ armed = api.GetMediaTrackInfo_Value(tr, "I_RECARM") ~= 0,
138
+ input = M.decode_input(api, api.GetMediaTrackInfo_Value(tr, "I_RECINPUT")),
139
+ monitor = MONITOR_NAME[monitor] or monitor,
140
+ monitorItems = api.GetMediaTrackInfo_Value(tr, "I_RECMONITEMS") ~= 0,
141
+ mode = MODE_NAME[mode] or mode,
142
+ inputFx = api.TrackFX_GetRecCount and api.TrackFX_GetRecCount(tr) or 0,
143
+ }
144
+ end
145
+ M.record_state = record_state
146
+
147
+ local function global_mode(api)
148
+ if api.GetToggleCommandState(M.RECORD_MODES.time_selection) == 1 then return "time_selection" end
149
+ if api.GetToggleCommandState(M.RECORD_MODES.selected_items) == 1 then return "selected_items" end
150
+ return "normal"
151
+ end
152
+
153
+ local function time_selection(api)
154
+ local s, e = api.GetSet_LoopTimeRange(false, false, 0, 0, false)
155
+ if type(s) == "number" and type(e) == "number" and e > s then return { start = round3(s), ["end"] = round3(e) } end
156
+ return nil
157
+ end
158
+
159
+ local function record_path(api)
160
+ local _, path = api.GetSetProjectInfo_String(0, "RECORD_PATH", "", false)
161
+ return path or ""
162
+ end
163
+
164
+ local function inputs(api)
165
+ local audio, midi = {}, {}
166
+ local n_audio = api.GetNumAudioInputs() or 0
167
+ for i = 0, math.min(n_audio, MAX_LANES) - 1 do
168
+ audio[#audio + 1] = { channel = i + 1, name = api.GetInputChannelName(i) or ("Input " .. (i + 1)) }
169
+ end
170
+ for i = 0, math.min(api.GetNumMIDIInputs() or 0, MIDI_VKB) - 1 do
171
+ local ok, name = api.GetMIDIInputName(i, "")
172
+ if ok then midi[#midi + 1] = { device = i, name = name } end
173
+ end
174
+ return { audioCount = n_audio, audio = audio, audioMore = math.max(0, n_audio - #audio), midi = midi }
175
+ end
176
+
177
+ --- The global record mode, punch range (time_selection mode) and record path
178
+ function M.global_state(api)
179
+ local mode = global_mode(api)
180
+ return { mode = mode, punch = mode == "time_selection" and time_selection(api) or nil, recordPath = record_path(api) }
181
+ end
182
+
183
+ --- get_record_setup: global mode, punch range, record path, the armed tracks
184
+ --- (or every track with `all`) and the inputs that exist.
185
+ function M.get_record_setup(api, params)
186
+ params = params or {}
187
+ local tracks, total = {}, api.CountTracks(0)
188
+ local listed, armed = 0, 0
189
+ for i = 0, total - 1 do
190
+ local tr = api.GetTrack(0, i)
191
+ local is_armed = api.GetMediaTrackInfo_Value(tr, "I_RECARM") ~= 0
192
+ if is_armed then armed = armed + 1 end
193
+ if params.all or is_armed then
194
+ listed = listed + 1
195
+ if #tracks < MAX_TRACKS then tracks[#tracks + 1] = record_state(api, tr) end
196
+ end
197
+ end
198
+ local mode = global_mode(api)
199
+ local path = record_path(api)
200
+ return {
201
+ mode = mode,
202
+ punch = mode == "time_selection" and time_selection(api) or nil,
203
+ recordPath = path,
204
+ projectPath = api.GetProjectPath and api.GetProjectPath("") or nil,
205
+ armedCount = armed,
206
+ tracks = tracks,
207
+ tracksMore = math.max(0, listed - #tracks),
208
+ inputs = inputs(api),
209
+ }
210
+ end
211
+
212
+ --- set_track_record: per-track arm, input, monitoring, record mode and
213
+ --- monitor-items, all validated before the first write; one undo point.
214
+ function M.set_track_record(api, updates, resolve_track)
215
+ if type(updates) ~= "table" or #updates == 0 then return nil, "tracks must be a non-empty array" end
216
+ if #updates > MAX_TRACKS then return nil, "at most " .. MAX_TRACKS .. " tracks per call" end
217
+ local plan, warnings, seen = {}, {}, {}
218
+ for k, u in ipairs(updates) do
219
+ local what = "tracks[" .. (k - 1) .. "]"
220
+ if type(u) ~= "table" then return nil, what .. " must be an object" end
221
+ local tr, err = resolve(api, u.track, resolve_track, what)
222
+ if not tr then return nil, err .. " — nothing changed" end
223
+ if seen[tr] then return nil, what .. ": track listed twice — nothing changed" end
224
+ seen[tr] = true
225
+ local p = { track = tr, sets = {} }
226
+ if u.arm ~= nil then p.sets[#p.sets + 1] = { "I_RECARM", u.arm and 1 or 0 } end
227
+ if u.input ~= nil then
228
+ local v, warn = encode_input(api, u.input, what)
229
+ if not v then return nil, warn .. " — nothing changed" end
230
+ if warn then warnings[#warnings + 1] = warn end
231
+ p.sets[#p.sets + 1] = { "I_RECINPUT", v }
232
+ end
233
+ if u.monitor ~= nil then
234
+ local v = M.MONITOR[u.monitor]
235
+ if not v then return nil, what .. ": monitor must be off, on or auto — nothing changed" end
236
+ p.sets[#p.sets + 1] = { "I_RECMON", v }
237
+ end
238
+ if u.mode ~= nil then
239
+ local v = M.TRACK_MODES[u.mode]
240
+ if not v then return nil, what .. ": unknown record mode '" .. tostring(u.mode) .. "' — nothing changed" end
241
+ p.sets[#p.sets + 1] = { "I_RECMODE", v }
242
+ end
243
+ if u.monitorItems ~= nil then p.sets[#p.sets + 1] = { "I_RECMONITEMS", u.monitorItems and 1 or 0 } end
244
+ if #p.sets == 0 then return nil, what .. ": nothing to set (arm, input, monitor, mode or monitorItems) — nothing changed" end
245
+ plan[k] = p
246
+ end
247
+
248
+ api.Undo_BeginBlock()
249
+ for _, p in ipairs(plan) do
250
+ for _, s in ipairs(p.sets) do api.SetMediaTrackInfo_Value(p.track, s[1], s[2]) end
251
+ end
252
+ api.Undo_EndBlock("MCP: Set track record", -1)
253
+
254
+ local out = {}
255
+ for k, p in ipairs(plan) do
256
+ out[k] = record_state(api, p.track)
257
+ -- MIDI mode on an audio input (or the reverse) records nothing useful.
258
+ local midi_mode = out[k].mode == "midi_overdub" or out[k].mode == "midi_replace" or out[k].mode == "midi_touch_replace"
259
+ or out[k].mode == "midi_latch_replace" or out[k].mode == "input_midi"
260
+ if midi_mode and out[k].input.kind == "audio" then
261
+ warnings[#warnings + 1] = out[k].name .. ": a MIDI record mode with an audio input records nothing"
262
+ end
263
+ if out[k].armed and out[k].input.kind == "none" and out[k].mode == "input" then
264
+ warnings[#warnings + 1] = out[k].name .. ": armed with no input — it records silence"
265
+ end
266
+ end
267
+ return { tracks = out, warnings = #warnings > 0 and warnings or nil }
268
+ end
269
+
270
+ --- set_record_mode: global record mode, optional punch range (as the time
271
+ --- selection) and record path.
272
+ function M.set_record_mode(api, params)
273
+ params = params or {}
274
+ local mode = params.mode
275
+ if mode ~= nil and not M.RECORD_MODES[mode] then
276
+ return nil, "mode must be normal, time_selection or selected_items — nothing changed"
277
+ end
278
+ local ps, pe = tonumber(params.punchStart), tonumber(params.punchEnd)
279
+ if (ps == nil) ~= (pe == nil) then return nil, "give both punchStart and punchEnd — nothing changed" end
280
+ if ps then
281
+ if ps < 0 or pe <= ps then return nil, "punchEnd must be after punchStart (seconds, from 0) — nothing changed" end
282
+ if (mode or global_mode(api)) ~= "time_selection" then
283
+ return nil, "a punch range needs mode time_selection — nothing changed"
284
+ end
285
+ end
286
+ local path = params.recordPath
287
+ if path ~= nil and (type(path) ~= "string" or path:match("^%s*$")) then
288
+ return nil, "recordPath must be a non-empty folder name or path — nothing changed"
289
+ end
290
+ if mode == nil and ps == nil and path == nil then return nil, "nothing to set (mode, punchStart/punchEnd or recordPath)" end
291
+
292
+ api.Undo_BeginBlock()
293
+ if mode and global_mode(api) ~= mode then api.Main_OnCommand(M.RECORD_MODES[mode], 0) end
294
+ local now = global_mode(api)
295
+ -- The mode is a toggle action REAPER may not run (another section focused, a newer action id): never report
296
+ -- a switch that did not happen, and stop before the punch range and path are written
297
+ if mode and now ~= mode then
298
+ api.Undo_EndBlock("MCP: Set record mode (failed)", -1)
299
+ return nil, "REAPER did not switch the record mode to " .. mode .. " (it reads " .. now .. ") — nothing changed"
300
+ end
301
+ if ps then api.GetSet_LoopTimeRange(true, false, ps, pe, false) end
302
+ if path then api.GetSetProjectInfo_String(0, "RECORD_PATH", path, true) end
303
+ api.Undo_EndBlock("MCP: Set record mode", -1)
304
+ return {
305
+ mode = now,
306
+ punch = now == "selected_items" and nil or (now == "time_selection" and time_selection(api) or nil),
307
+ recordPath = record_path(api),
308
+ warnings = (now == "time_selection" and not time_selection(api))
309
+ and { "time_selection mode with no time selection records nothing until one is set" } or nil,
310
+ }
311
+ end
312
+
313
+ -- =============================================================================
314
+ -- Fixed lanes
315
+ -- =============================================================================
316
+
317
+ local function fixed(api, tr) return int(api.GetMediaTrackInfo_Value(tr, "I_FREEMODE")) == 2 end
318
+ local function lane_count(api, tr) return math.max(1, int(api.GetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES"))) end
319
+ local function lane_name(api, tr, n)
320
+ local _, name = api.GetSetMediaTrackInfo_String(tr, "P_LANENAME:" .. n, "", false)
321
+ return name or ""
322
+ end
323
+ local PLAYS = { [0] = "off", [1] = "exclusive", [2] = "layered" }
324
+
325
+ local function item_info(api, it, k)
326
+ local take = api.GetActiveTake(it)
327
+ local name = take and api.GetTakeName(take) or ""
328
+ return {
329
+ index = k,
330
+ position = round3(api.GetMediaItemInfo_Value(it, "D_POSITION")),
331
+ length = round3(api.GetMediaItemInfo_Value(it, "D_LENGTH")),
332
+ name = name,
333
+ midi = take ~= nil and api.TakeIsMIDI(take) or false,
334
+ }
335
+ end
336
+
337
+ --- The track's lanes: name, play state and items (capped per lane).
338
+ function M.lanes_state(api, tr)
339
+ local on = fixed(api, tr)
340
+ local count = on and lane_count(api, tr) or 1
341
+ local lanes = {}
342
+ for n = 0, count - 1 do
343
+ local plays = on and int(api.GetMediaTrackInfo_Value(tr, "C_LANEPLAYS:" .. n)) or 1
344
+ lanes[n + 1] = { lane = n, name = on and lane_name(api, tr, n) or "", plays = PLAYS[plays] or plays, items = {}, itemsMore = 0 }
345
+ end
346
+ for k = 0, api.CountTrackMediaItems(tr) - 1 do
347
+ local it = api.GetTrackMediaItem(tr, k)
348
+ local n = on and int(api.GetMediaItemInfo_Value(it, "I_FIXEDLANE")) or 0
349
+ local lane = lanes[n + 1]
350
+ if lane then
351
+ if #lane.items < MAX_LANE_ITEMS then lane.items[#lane.items + 1] = item_info(api, it, k)
352
+ else lane.itemsMore = lane.itemsMore + 1 end
353
+ end
354
+ end
355
+ return { index = track_index(api, tr), name = track_name(api, tr), fixedLanes = on, laneCount = count, lanes = lanes }
356
+ end
357
+
358
+ function M.get_track_lanes(api, params, resolve_track)
359
+ local tr, err = resolve(api, params.track, resolve_track)
360
+ if not tr then return nil, err end
361
+ return M.lanes_state(api, tr)
362
+ end
363
+
364
+ local function highest_used_lane(api, tr)
365
+ local top = 0
366
+ for k = 0, api.CountTrackMediaItems(tr) - 1 do
367
+ top = math.max(top, int(api.GetMediaItemInfo_Value(api.GetTrackMediaItem(tr, k), "I_FIXEDLANE")))
368
+ end
369
+ return top
370
+ end
371
+
372
+ local function lanes_with_items(api, tr)
373
+ local used = {}
374
+ for k = 0, api.CountTrackMediaItems(tr) - 1 do
375
+ used[int(api.GetMediaItemInfo_Value(api.GetTrackMediaItem(tr, k), "I_FIXEDLANE"))] = true
376
+ end
377
+ local n = 0
378
+ for _ in pairs(used) do n = n + 1 end
379
+ return n
380
+ end
381
+
382
+ --- set_track_lanes: fixed lanes on/off, lane count, names and which lanes play.
383
+ --- `play` is a lane number (plays alone) or an array of lanes (layered).
384
+ function M.set_track_lanes(api, params, resolve_track)
385
+ local tr, err = resolve(api, params.track, resolve_track)
386
+ if not tr then return nil, err end
387
+ local was_on = fixed(api, tr)
388
+ local on = params.fixedLanes == nil and was_on or params.fixedLanes == true
389
+ local warnings = {}
390
+ local count = on and (was_on and lane_count(api, tr) or 1) or 1
391
+
392
+ local want_count = params.laneCount ~= nil and tonumber(params.laneCount) or nil
393
+ if params.laneCount ~= nil then
394
+ if not want_count or want_count < 1 or want_count > MAX_LANES or want_count ~= math.floor(want_count) then
395
+ return nil, "laneCount must be 1-" .. MAX_LANES .. " — nothing changed"
396
+ end
397
+ if not on then return nil, "laneCount needs fixed lanes (set fixedLanes: true) — nothing changed" end
398
+ local top = was_on and highest_used_lane(api, tr) or 0
399
+ if want_count <= top then
400
+ return nil, "lane " .. top .. " still has items: laneCount must be at least " .. (top + 1) .. " — nothing changed"
401
+ end
402
+ count = int(want_count)
403
+ end
404
+
405
+ local names = params.names
406
+ if names ~= nil then
407
+ if type(names) ~= "table" then return nil, "names must be an array of { lane, name } — nothing changed" end
408
+ if not on then return nil, "lane names need fixed lanes (set fixedLanes: true) — nothing changed" end
409
+ for k, n in ipairs(names) do
410
+ local lane = type(n) == "table" and tonumber(n.lane)
411
+ if not lane or lane < 0 or lane >= count or type(n.name) ~= "string" then
412
+ return nil, "names[" .. (k - 1) .. "]: lane must be 0-" .. (count - 1) .. " with a string name — nothing changed"
413
+ end
414
+ end
415
+ end
416
+
417
+ local play
418
+ if params.play ~= nil then
419
+ if not on then return nil, "play needs fixed lanes (set fixedLanes: true) — nothing changed" end
420
+ local list = type(params.play) == "table" and params.play or { params.play }
421
+ if #list == 0 then return nil, "play must be a lane number or a non-empty array of lanes — nothing changed" end
422
+ play = {}
423
+ for _, n in ipairs(list) do
424
+ local lane = tonumber(n)
425
+ if not lane or lane < 0 or lane >= count or lane ~= math.floor(lane) then
426
+ return nil, "play: lane " .. tostring(n) .. " does not exist (lanes 0-" .. (count - 1) .. ") — nothing changed"
427
+ end
428
+ play[int(lane)] = true
429
+ end
430
+ play.layered = type(params.play) == "table"
431
+ end
432
+
433
+ if params.fixedLanes == nil and params.laneCount == nil and names == nil and play == nil then
434
+ return nil, "nothing to set (fixedLanes, laneCount, names or play)"
435
+ end
436
+ if was_on and not on and lanes_with_items(api, tr) > 1 then
437
+ warnings[#warnings + 1] = "fixed lanes off: the items of every lane now sit on one lane and all play — undo_project brings the lanes back"
438
+ end
439
+
440
+ api.Undo_BeginBlock()
441
+ if on ~= was_on then api.SetMediaTrackInfo_Value(tr, "I_FREEMODE", on and 2 or 0) end
442
+ if on and want_count then api.SetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES", count) end
443
+ for _, n in ipairs(names or {}) do api.GetSetMediaTrackInfo_String(tr, "P_LANENAME:" .. int(n.lane), n.name, true) end
444
+ if play then
445
+ for n = 0, count - 1 do
446
+ api.SetMediaTrackInfo_Value(tr, "C_LANEPLAYS:" .. n, play[n] and (play.layered and 2 or 1) or 0)
447
+ end
448
+ end
449
+ api.Undo_EndBlock("MCP: Set track lanes", -1)
450
+ if api.UpdateArrange then api.UpdateArrange() end
451
+
452
+ local out = M.lanes_state(api, tr)
453
+ out.warnings = #warnings > 0 and warnings or nil
454
+ return out
455
+ end
456
+
457
+ -- =============================================================================
458
+ -- Comping
459
+ -- =============================================================================
460
+
461
+ -- A copy of `src` (via its state chunk, GUIDs stripped so REAPER makes new
462
+ -- ones) trimmed to [from, to] on `lane`.
463
+ local function copy_segment(api, tr, src, from, to, lane)
464
+ local ok, chunk = api.GetItemStateChunk(src, "", false)
465
+ if not ok then error("could not read an item on the source lane", 0) end
466
+ local lines = {}
467
+ for line in (chunk .. "\n"):gmatch("(.-)\r?\n") do
468
+ if not (line:match("^%s*IGUID ") or line:match("^%s*GUID ") or line:match("^%s*POOLEDEVTS ")) then
469
+ lines[#lines + 1] = line
470
+ end
471
+ end
472
+ local it = api.AddMediaItemToTrack(tr)
473
+ api.SetItemStateChunk(it, table.concat(lines, "\n"), false)
474
+ local pos = api.GetMediaItemInfo_Value(src, "D_POSITION")
475
+ for t = 0, api.CountTakes(it) - 1 do
476
+ local take = api.GetTake(it, t)
477
+ local rate = api.GetMediaItemTakeInfo_Value(take, "D_PLAYRATE")
478
+ local offs = api.GetMediaItemTakeInfo_Value(take, "D_STARTOFFS")
479
+ api.SetMediaItemTakeInfo_Value(take, "D_STARTOFFS", offs + (from - pos) * (rate > 0 and rate or 1))
480
+ end
481
+ api.SetMediaItemInfo_Value(it, "D_POSITION", from)
482
+ api.SetMediaItemInfo_Value(it, "D_LENGTH", to - from)
483
+ api.SetMediaItemInfo_Value(it, "I_FIXEDLANE", lane)
484
+ api.SetMediaItemInfo_Value(it, "B_UISEL", 0)
485
+ return it
486
+ end
487
+
488
+ --- comp_lanes: build a comp on a new lane from time ranges of the source
489
+ --- lanes, crossfaded at each switch, and play it alone.
490
+ function M.comp_lanes(api, params, resolve_track)
491
+ local tr, err = resolve(api, params.track, resolve_track)
492
+ if not tr then return nil, err end
493
+ if not fixed(api, tr) then return nil, "the track has no fixed lanes (set_track_lanes fixedLanes: true, or record in lanes first)" end
494
+ local count = lane_count(api, tr)
495
+ if count >= MAX_LANES then return nil, "the track already has " .. count .. " lanes (max " .. MAX_LANES .. ")" end
496
+ local ranges = params.ranges
497
+ if type(ranges) ~= "table" or #ranges == 0 then return nil, "ranges must be a non-empty array of { start, end, lane }" end
498
+ if #ranges > MAX_RANGES then return nil, "at most " .. MAX_RANGES .. " ranges per comp" end
499
+ local xf = tonumber(params.crossfadeMs or 10)
500
+ if not xf or xf < 0 or xf > 500 then return nil, "crossfadeMs must be 0-500" end
501
+ xf = xf / 1000
502
+
503
+ -- Items per lane, sorted by position.
504
+ local by_lane = {}
505
+ for k = 0, api.CountTrackMediaItems(tr) - 1 do
506
+ local it = api.GetTrackMediaItem(tr, k)
507
+ local n = int(api.GetMediaItemInfo_Value(it, "I_FIXEDLANE"))
508
+ local pos = api.GetMediaItemInfo_Value(it, "D_POSITION")
509
+ by_lane[n] = by_lane[n] or {}
510
+ table.insert(by_lane[n], { item = it, s = pos, e = pos + api.GetMediaItemInfo_Value(it, "D_LENGTH") })
511
+ end
512
+ for _, list in pairs(by_lane) do table.sort(list, function(a, b) return a.s < b.s end) end
513
+
514
+ local rs = {}
515
+ for k, r in ipairs(ranges) do
516
+ local what = "ranges[" .. (k - 1) .. "]"
517
+ local s, e, lane = type(r) == "table" and tonumber(r.start), type(r) == "table" and tonumber(r["end"]), type(r) == "table" and tonumber(r.lane)
518
+ if not s or not e or not lane then return nil, what .. " needs start, end (seconds) and lane — nothing changed" end
519
+ if s < 0 or e <= s then return nil, what .. ": end must be after start — nothing changed" end
520
+ if lane < 0 or lane >= count or lane ~= math.floor(lane) then
521
+ return nil, what .. ": lane " .. lane .. " does not exist (lanes 0-" .. (count - 1) .. ") — nothing changed"
522
+ end
523
+ rs[#rs + 1] = { s = s, e = e, lane = int(lane), k = k - 1 }
524
+ end
525
+ table.sort(rs, function(a, b) return a.s < b.s end)
526
+ for i = 2, #rs do
527
+ if rs[i].s < rs[i - 1].e - 1e-9 then
528
+ return nil, "ranges[" .. rs[i - 1].k .. "] and ranges[" .. rs[i].k .. "] overlap — nothing changed"
529
+ end
530
+ end
531
+
532
+ -- Each range must be covered by audio on its lane, with no gap.
533
+ local segments = {}
534
+ for _, r in ipairs(rs) do
535
+ local at = r.s
536
+ for _, src in ipairs(by_lane[r.lane] or {}) do
537
+ if src.e > at + 1e-9 and src.s < r.e - 1e-9 then
538
+ if src.s > at + 1e-6 then break end
539
+ local to = math.min(src.e, r.e)
540
+ segments[#segments + 1] = { src = src, s = at, e = to, lane = r.lane }
541
+ at = to
542
+ if at >= r.e - 1e-9 then break end
543
+ end
544
+ end
545
+ if at < r.e - 1e-6 then
546
+ local gap_end = r.e
547
+ for _, src in ipairs(by_lane[r.lane] or {}) do
548
+ if src.s > at + 1e-6 then gap_end = math.min(gap_end, src.s); break end
549
+ end
550
+ return nil, "lane " .. r.lane .. " has no audio from " .. secs(at) .. " s to " .. secs(gap_end) .. " s (ranges[" .. r.k .. "]) — nothing changed"
551
+ end
552
+ end
553
+
554
+ local comp_lane = count
555
+ local label = "MCP: Comp lanes"
556
+ api.Undo_BeginBlock()
557
+ api.PreventUIRefresh(1)
558
+ local ok, res = pcall(function()
559
+ api.SetMediaTrackInfo_Value(tr, "I_NUMFIXEDLANES", count + 1)
560
+ api.GetSetMediaTrackInfo_String(tr, "P_LANENAME:" .. comp_lane, type(params.name) == "string" and params.name ~= "" and params.name or "Comp", true)
561
+ local made = {}
562
+ for i, seg in ipairs(segments) do
563
+ local nxt, prev = segments[i + 1], segments[i - 1]
564
+ local joins_next = nxt and math.abs(nxt.s - seg.e) < 1e-6
565
+ local joins_prev = prev and math.abs(prev.e - seg.s) < 1e-6
566
+ -- Overlap the next segment by the crossfade, as far as this source item reaches and
567
+ -- never past half the next segment, so a short part is still heard.
568
+ local tail = joins_next and math.min(xf, seg.src.e - seg.e, (nxt.e - nxt.s) / 2) or 0
569
+ local it = copy_segment(api, tr, seg.src.item, seg.s, seg.e + tail, comp_lane)
570
+ local keep_in = math.abs(seg.s - seg.src.s) < 1e-6 and api.GetMediaItemInfo_Value(seg.src.item, "D_FADEINLEN") or 0
571
+ local keep_out = math.abs(seg.e - seg.src.e) < 1e-6 and api.GetMediaItemInfo_Value(seg.src.item, "D_FADEOUTLEN") or 0
572
+ local fade_in = joins_prev and (made[i - 1] and made[i - 1].tail or 0) or keep_in
573
+ api.SetMediaItemInfo_Value(it, "D_FADEINLEN", fade_in)
574
+ api.SetMediaItemInfo_Value(it, "D_FADEOUTLEN", joins_next and tail or keep_out)
575
+ made[i] = { item = it, tail = tail, s = round3(seg.s), e = round3(seg.e), lane = seg.lane, fadeMs = round3(tail * 1000) }
576
+ end
577
+ for n = 0, count do api.SetMediaTrackInfo_Value(tr, "C_LANEPLAYS:" .. n, n == comp_lane and 1 or 0) end
578
+ return made
579
+ end)
580
+ api.PreventUIRefresh(-1)
581
+ api.Undo_EndBlock(label, -1)
582
+ if not ok then
583
+ if api.Undo_CanUndo2(0) == label then api.Undo_DoUndo2(0) end
584
+ return nil, "comp failed and was undone: " .. tostring(res)
585
+ end
586
+ if api.UpdateArrange then api.UpdateArrange() end
587
+
588
+ local out = M.lanes_state(api, tr)
589
+ out.compLane = comp_lane
590
+ out.segments = {}
591
+ for i, m in ipairs(res) do out.segments[i] = { start = m.s, ["end"] = m.e, lane = m.lane, crossfadeMs = m.fadeMs } end
592
+ return out
593
+ end
594
+
595
+ return M