@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,409 @@
1
+ -- mcp_tracks_lib.lua — track structure and routing (sends, receives,
2
+ -- hardware outputs, master send, sidechain channels, groups/VCA).
3
+ --
4
+ -- Pure logic: every function takes the ReaScript API as `api` and tracks
5
+ -- already resolved by the caller, so it runs under a fake API in unit tests.
6
+ -- Loaded by mcp_bridge.lua with dofile.
7
+
8
+ local M = {}
9
+
10
+ local function to_db(v)
11
+ if v <= 0 then return -150 end
12
+ return 20 * math.log(v, 10)
13
+ end
14
+
15
+ local function from_db(db)
16
+ if db <= -150 then return 0 end
17
+ return 10 ^ (db / 20)
18
+ end
19
+
20
+ local function is_master(api, tr)
21
+ return tr == api.GetMasterTrack(0)
22
+ end
23
+
24
+ local function track_index(api, tr)
25
+ if is_master(api, tr) then return -1 end
26
+ return math.floor(api.GetMediaTrackInfo_Value(tr, "IP_TRACKNUMBER")) - 1
27
+ end
28
+
29
+ local function track_name(api, tr)
30
+ if is_master(api, tr) then return "MASTER" end
31
+ local _, name = api.GetTrackName(tr)
32
+ return name
33
+ end
34
+
35
+ local function label(api, tr)
36
+ return { index = track_index(api, tr), name = track_name(api, tr) }
37
+ end
38
+
39
+ -- ---------------------------------------------------------------------------
40
+ -- Colors: "#RRGGBB" <-> REAPER's native color with the custom-color flag.
41
+ -- ---------------------------------------------------------------------------
42
+
43
+ local CUSTOM_COLOR = 0x1000000
44
+
45
+ function M.parse_color(s)
46
+ if s == nil or s == "" or s == "none" then return 0 end
47
+ local hex = tostring(s):match("^#?(%x%x%x%x%x%x)$")
48
+ if not hex then return nil, "color must be #RRGGBB or \"none\", got '" .. tostring(s) .. "'" end
49
+ return tonumber(hex:sub(1, 2), 16), tonumber(hex:sub(3, 4), 16), tonumber(hex:sub(5, 6), 16)
50
+ end
51
+
52
+ local function read_color(api, tr)
53
+ local c = math.floor(api.GetMediaTrackInfo_Value(tr, "I_CUSTOMCOLOR"))
54
+ if c & CUSTOM_COLOR == 0 then return nil end
55
+ local r, g, b = api.ColorFromNative(c & 0xFFFFFF)
56
+ return string.format("#%02X%02X%02X", r, g, b)
57
+ end
58
+
59
+ -- ---------------------------------------------------------------------------
60
+ -- Track structure
61
+ -- ---------------------------------------------------------------------------
62
+
63
+ local FOLDER_MODES = { normal = 0, folder = 1, last = -1 }
64
+
65
+ function M.describe_track(api, tr)
66
+ local out = label(api, tr)
67
+ out.guid = api.GetTrackGUID(tr)
68
+ out.color = read_color(api, tr)
69
+ out.folderDepth = math.floor(api.GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH"))
70
+ out.folderCollapsed = math.floor(api.GetMediaTrackInfo_Value(tr, "I_FOLDERCOMPACT"))
71
+ out.channels = math.floor(api.GetMediaTrackInfo_Value(tr, "I_NCHAN"))
72
+ out.masterSend = api.GetMediaTrackInfo_Value(tr, "B_MAINSEND") ~= 0
73
+ return out
74
+ end
75
+
76
+ --- add_track: insert at `index` (default: end) with an optional name.
77
+ function M.add_track(api, params)
78
+ local count = api.CountTracks(0)
79
+ local at = tonumber(params.index) or count
80
+ if at < 0 or at > count then
81
+ return nil, "index " .. tostring(params.index) .. " is out of range (0-" .. count .. ")"
82
+ end
83
+ api.InsertTrackAtIndex(at, true)
84
+ local tr = api.GetTrack(0, at)
85
+ if params.name and params.name ~= "" then
86
+ api.GetSetMediaTrackInfo_String(tr, "P_NAME", params.name, true)
87
+ end
88
+ return M.describe_track(api, tr)
89
+ end
90
+
91
+ --- delete_track: the master can never be deleted.
92
+ function M.delete_track(api, tr)
93
+ if is_master(api, tr) then return nil, "the master track cannot be deleted" end
94
+ local gone = label(api, tr)
95
+ api.DeleteTrack(tr)
96
+ return { deleted = gone, trackCount = api.CountTracks(0) }
97
+ end
98
+
99
+ --- move_track: move one track so it ends up at `to` (0-based, in the final
100
+ --- list). Uses ReorderSelectedTracks, so the selection is saved and restored.
101
+ function M.move_track(api, tr, to)
102
+ if is_master(api, tr) then return nil, "the master track cannot be moved" end
103
+ local count = api.CountTracks(0)
104
+ to = tonumber(to)
105
+ if not to or to < 0 or to >= count then
106
+ return nil, "toIndex " .. tostring(to) .. " is out of range (0-" .. (count - 1) .. ")"
107
+ end
108
+ local from = track_index(api, tr)
109
+ if from == to then return { track = label(api, tr), moved = false } end
110
+
111
+ local saved = {}
112
+ for i = 0, count - 1 do
113
+ local t = api.GetTrack(0, i)
114
+ saved[#saved + 1] = { t, api.IsTrackSelected(t) }
115
+ api.SetTrackSelected(t, t == tr)
116
+ end
117
+ -- "before" is an index in the list as it is now; moving down skips the
118
+ -- moved track's own slot.
119
+ local before = to > from and to + 1 or to
120
+ local ok = api.ReorderSelectedTracks(before, 0)
121
+ for _, s in ipairs(saved) do api.SetTrackSelected(s[1], s[2]) end
122
+ if not ok then return nil, "REAPER refused to move the track" end
123
+ return { track = label(api, tr), moved = true, from = from }
124
+ end
125
+
126
+ --- set_track_folder: mode folder | normal | last (closes the enclosing
127
+ --- folder), or a raw depth change; optional collapsed state 0/1/2.
128
+ function M.set_track_folder(api, tr, params)
129
+ if is_master(api, tr) then return nil, "the master track cannot be in a folder" end
130
+ local depth = FOLDER_MODES[params.mode or ""]
131
+ if depth == nil then depth = tonumber(params.depth) end
132
+ if depth == nil and params.collapsed == nil then
133
+ return nil, "give mode (folder, normal or last), depth, or collapsed"
134
+ end
135
+ if depth ~= nil then api.SetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH", depth) end
136
+ if params.collapsed ~= nil then
137
+ local c = tonumber(params.collapsed)
138
+ if c == nil or c < 0 or c > 2 then return nil, "collapsed must be 0 (open), 1 (small) or 2 (collapsed)" end
139
+ api.SetMediaTrackInfo_Value(tr, "I_FOLDERCOMPACT", c)
140
+ end
141
+ return M.describe_track(api, tr)
142
+ end
143
+
144
+ --- set_track_info: name, color, channel count, master/parent send. Every
145
+ --- value is checked before anything is written.
146
+ function M.set_track_info(api, tr, params)
147
+ local r, g, b
148
+ if params.color ~= nil then
149
+ r, g, b = M.parse_color(params.color)
150
+ if r == nil then return nil, g end
151
+ end
152
+ local nchan = params.channels ~= nil and tonumber(params.channels) or nil
153
+ if params.channels ~= nil and (not nchan or nchan < 2 or nchan > 128 or nchan % 2 ~= 0) then
154
+ return nil, "channels must be an even number from 2 to 128"
155
+ end
156
+ if params.name ~= nil and is_master(api, tr) then return nil, "the master track cannot be renamed" end
157
+ if params.masterSend ~= nil and is_master(api, tr) then return nil, "the master track has no master send" end
158
+
159
+ if params.name ~= nil then api.GetSetMediaTrackInfo_String(tr, "P_NAME", tostring(params.name), true) end
160
+ if params.color ~= nil then
161
+ local native = g == nil and 0 or (api.ColorToNative(r, g, b) | CUSTOM_COLOR)
162
+ api.SetMediaTrackInfo_Value(tr, "I_CUSTOMCOLOR", native)
163
+ end
164
+ if nchan then api.SetMediaTrackInfo_Value(tr, "I_NCHAN", nchan) end
165
+ if params.masterSend ~= nil then api.SetMediaTrackInfo_Value(tr, "B_MAINSEND", params.masterSend and 1 or 0) end
166
+ return M.describe_track(api, tr)
167
+ end
168
+
169
+ -- ---------------------------------------------------------------------------
170
+ -- Routing
171
+ -- ---------------------------------------------------------------------------
172
+
173
+ M.CATEGORIES = { send = 0, receive = -1, hardware = 1 }
174
+ local SEND_MODES = { ["post-fader"] = 0, ["pre-fx"] = 1, ["post-fx"] = 3 }
175
+ local MODE_NAMES = { [0] = "post-fader", [1] = "pre-fx", [3] = "post-fx" }
176
+
177
+ local function category(name)
178
+ local c = M.CATEGORIES[name or "send"]
179
+ if c == nil then error("unknown category '" .. tostring(name) .. "' (use send, receive or hardware)", 0) end
180
+ return c
181
+ end
182
+
183
+ -- A channel pair is given as its first channel, 1-based (1 = 1/2, 3 = 3/4).
184
+ -- REAPER stores the 0-based offset; -1 on a source means "no audio".
185
+ local function chan_offset(first, what)
186
+ if first == nil then return nil end
187
+ local n = tonumber(first)
188
+ if n == -1 and what == "srcChannel" then return -1 end
189
+ if not n or n < 1 or n > 127 or n % 1 ~= 0 then
190
+ return nil, what .. " must be the first channel of the pair, 1-127 (1 = 1/2, 3 = 3/4)"
191
+ end
192
+ return n - 1
193
+ end
194
+
195
+ local function describe_route(api, tr, cat, i)
196
+ local v = function(k) return api.GetTrackSendInfo_Value(tr, cat, i, k) end
197
+ local mode = math.floor(v("I_SENDMODE"))
198
+ local src = math.floor(v("I_SRCCHAN"))
199
+ local dst = math.floor(v("I_DSTCHAN"))
200
+ local out = {
201
+ index = i,
202
+ volume = to_db(v("D_VOL")),
203
+ pan = v("D_PAN"),
204
+ muted = v("B_MUTE") ~= 0,
205
+ mode = MODE_NAMES[mode] or tostring(mode),
206
+ srcChannel = src < 0 and -1 or (src & 0x3FF) + 1,
207
+ }
208
+ if cat == 1 then
209
+ out.outputChannel = (dst & 0x3FF) + 1
210
+ out.mono = dst & 1024 ~= 0
211
+ else
212
+ out.destChannel = (dst & 0x3FF) + 1
213
+ local other = cat == 0 and v("P_DESTTRACK") or v("P_SRCTRACK")
214
+ if other and other ~= 0 then
215
+ local who = label(api, other)
216
+ if cat == 0 then out.destTrack = who else out.srcTrack = who end
217
+ end
218
+ end
219
+ return out
220
+ end
221
+
222
+ --- routing: everything get_track_routing reports beyond the parent/folder.
223
+ function M.routing(api, tr)
224
+ local function list(cat)
225
+ local out = {}
226
+ for i = 0, api.GetTrackNumSends(tr, cat) - 1 do out[#out + 1] = describe_route(api, tr, cat, i) end
227
+ return out
228
+ end
229
+ local master_send -- nil on the master, which has none
230
+ if not is_master(api, tr) then master_send = api.GetMediaTrackInfo_Value(tr, "B_MAINSEND") ~= 0 end
231
+ return {
232
+ sends = list(0),
233
+ receives = list(-1),
234
+ hardwareOutputs = list(1),
235
+ masterSend = master_send,
236
+ channels = math.floor(api.GetMediaTrackInfo_Value(tr, "I_NCHAN")),
237
+ }
238
+ end
239
+
240
+ -- Validate and collect the writable send fields; nothing is written here.
241
+ local function send_fields(params)
242
+ local f = {}
243
+ if params.volume ~= nil then
244
+ local db = tonumber(params.volume)
245
+ if not db then return nil, "volume must be a number in dB" end
246
+ f.D_VOL = from_db(db)
247
+ end
248
+ if params.pan ~= nil then
249
+ local p = tonumber(params.pan)
250
+ if not p or p < -1 or p > 1 then return nil, "pan must be from -1 to 1" end
251
+ f.D_PAN = p
252
+ end
253
+ if params.mute ~= nil then f.B_MUTE = params.mute and 1 or 0 end
254
+ if params.mode ~= nil then
255
+ f.I_SENDMODE = SEND_MODES[params.mode]
256
+ if f.I_SENDMODE == nil then return nil, "mode must be post-fader, pre-fx or post-fx" end
257
+ end
258
+ local err
259
+ f.I_SRCCHAN, err = chan_offset(params.srcChannel, "srcChannel")
260
+ if err then return nil, err end
261
+ f.I_DSTCHAN, err = chan_offset(params.destChannel, "destChannel")
262
+ if err then return nil, err end
263
+ return f
264
+ end
265
+
266
+ -- Sidechain: a send into channels 3/4 needs a destination with 4+ channels.
267
+ local function ensure_channels(api, dest, dst_offset)
268
+ if not dest or not dst_offset or dst_offset < 0 then return end
269
+ local need = dst_offset + 2
270
+ if need % 2 == 1 then need = need + 1 end
271
+ if api.GetMediaTrackInfo_Value(dest, "I_NCHAN") < need then
272
+ api.SetMediaTrackInfo_Value(dest, "I_NCHAN", need)
273
+ end
274
+ end
275
+
276
+ local function apply_fields(api, tr, cat, i, f)
277
+ for _, k in ipairs({ "D_VOL", "D_PAN", "B_MUTE", "I_SENDMODE", "I_SRCCHAN", "I_DSTCHAN" }) do
278
+ if f[k] ~= nil then api.SetTrackSendInfo_Value(tr, cat, i, k, f[k]) end
279
+ end
280
+ end
281
+
282
+ --- add_send: track -> track. Options: volume (dB), pan, mute, mode,
283
+ --- srcChannel / destChannel (first channel of a pair; 3 = sidechain 3/4).
284
+ function M.add_send(api, src, dest, params)
285
+ if src == dest then return nil, "a track cannot send to itself" end
286
+ if is_master(api, src) then return nil, "the master track cannot send to another track" end
287
+ -- REAPER creates nothing for a send into the master: tracks reach it through B_MAINSEND.
288
+ if is_master(api, dest) then return nil, "a track reaches the master through its master send, not a send: use set_track_info masterSend" end
289
+ local f, err = send_fields(params or {})
290
+ if not f then return nil, err end
291
+ local i = api.CreateTrackSend(src, dest)
292
+ if not i or i < 0 then return nil, "REAPER refused the send" end
293
+ ensure_channels(api, dest, f.I_DSTCHAN)
294
+ apply_fields(api, src, 0, i, f)
295
+ return { track = label(api, src), send = describe_route(api, src, 0, i) }
296
+ end
297
+
298
+ --- add_hardware_output: route a track to an audio output (outputChannel is
299
+ --- 1-based; mono uses one channel).
300
+ function M.add_hardware_output(api, tr, params)
301
+ local ch = tonumber(params.outputChannel or 1)
302
+ if not ch or ch < 1 or ch > 512 or ch % 1 ~= 0 then return nil, "outputChannel must be 1-512" end
303
+ local f, err = send_fields({ volume = params.volume, pan = params.pan, mute = params.mute, mode = params.mode })
304
+ if not f then return nil, err end
305
+ local i = api.CreateTrackSend(tr, nil)
306
+ if not i or i < 0 then return nil, "REAPER refused the hardware output" end
307
+ f.I_DSTCHAN = (ch - 1) | (params.mono and 1024 or 0)
308
+ apply_fields(api, tr, 1, i, f)
309
+ return { track = label(api, tr), hardwareOutput = describe_route(api, tr, 1, i) }
310
+ end
311
+
312
+ --- set_send: edit an existing send, receive or hardware output by index.
313
+ function M.set_send(api, tr, params)
314
+ local cat = category(params.category)
315
+ local i = tonumber(params.index)
316
+ local n = api.GetTrackNumSends(tr, cat)
317
+ if not i or i < 0 or i >= n then
318
+ return nil, (params.category or "send") .. " " .. tostring(params.index) .. " not found on track (it has " .. n .. ")"
319
+ end
320
+ local f, err = send_fields(params)
321
+ if not f then return nil, err end
322
+ if cat == 1 and params.destChannel ~= nil then return nil, "use outputChannel for a hardware output" end
323
+ if cat == 1 and (params.outputChannel ~= nil or params.mono ~= nil) then
324
+ local ch = tonumber(params.outputChannel)
325
+ if params.outputChannel ~= nil and (not ch or ch < 1 or ch > 512) then return nil, "outputChannel must be 1-512" end
326
+ f.I_DSTCHAN = ch and (ch - 1) or (math.floor(api.GetTrackSendInfo_Value(tr, cat, i, "I_DSTCHAN")) & 0x3FF)
327
+ if params.mono ~= nil then f.I_DSTCHAN = f.I_DSTCHAN | (params.mono and 1024 or 0) end
328
+ end
329
+ -- A channel edit moves only the offset (low 10 bits): it keeps the source
330
+ -- width (I_SRCCHAN >> 10) and the destination's mix-to-mono bit (1024).
331
+ local keep = { I_SRCCHAN = ~0x3FF, I_DSTCHAN = 1024 }
332
+ for k, mask in pairs(keep) do
333
+ local cur = math.floor(api.GetTrackSendInfo_Value(tr, cat, i, k))
334
+ if f[k] and f[k] >= 0 and cur >= 0 and not (k == "I_DSTCHAN" and cat == 1 and params.mono ~= nil) then
335
+ f[k] = f[k] | (cur & mask)
336
+ end
337
+ end
338
+ local dst_offset = f.I_DSTCHAN and f.I_DSTCHAN & 0x3FF
339
+ if cat == 0 then ensure_channels(api, api.GetTrackSendInfo_Value(tr, 0, i, "P_DESTTRACK"), dst_offset) end
340
+ if cat == -1 then ensure_channels(api, tr, dst_offset) end
341
+ apply_fields(api, tr, cat, i, f)
342
+ return { track = label(api, tr), [params.category or "send"] = describe_route(api, tr, cat, i) }
343
+ end
344
+
345
+ --- remove_send: remove a send, receive or hardware output by index.
346
+ function M.remove_send(api, tr, params)
347
+ local cat = category(params.category)
348
+ local i = tonumber(params.index)
349
+ local n = api.GetTrackNumSends(tr, cat)
350
+ if not i or i < 0 or i >= n then
351
+ return nil, (params.category or "send") .. " " .. tostring(params.index) .. " not found on track (it has " .. n .. ")"
352
+ end
353
+ api.RemoveTrackSend(tr, cat, i)
354
+ return { track = label(api, tr), removed = i, remaining = api.GetTrackNumSends(tr, cat) }
355
+ end
356
+
357
+ -- ---------------------------------------------------------------------------
358
+ -- Groups / VCA
359
+ -- ---------------------------------------------------------------------------
360
+
361
+ M.GROUP_ROLES = {
362
+ volume_lead = "VOLUME_LEAD", volume_follow = "VOLUME_FOLLOW",
363
+ vca_lead = "VOLUME_VCA_LEAD", vca_follow = "VOLUME_VCA_FOLLOW",
364
+ pan_lead = "PAN_LEAD", pan_follow = "PAN_FOLLOW",
365
+ mute_lead = "MUTE_LEAD", mute_follow = "MUTE_FOLLOW",
366
+ solo_lead = "SOLO_LEAD", solo_follow = "SOLO_FOLLOW",
367
+ recarm_lead = "RECARM_LEAD", recarm_follow = "RECARM_FOLLOW",
368
+ polarity_lead = "POLARITY_LEAD", polarity_follow = "POLARITY_FOLLOW",
369
+ automode_lead = "AUTOMODE_LEAD", automode_follow = "AUTOMODE_FOLLOW",
370
+ }
371
+
372
+ local function group_call(api, group)
373
+ if group <= 32 then return api.GetSetTrackGroupMembership, 1 << (group - 1) end
374
+ return api.GetSetTrackGroupMembershipHigh, 1 << (group - 33)
375
+ end
376
+
377
+ --- The roles a track has in one group (1-64).
378
+ function M.group_roles(api, tr, group)
379
+ local fn, bit = group_call(api, group)
380
+ local roles = {}
381
+ for role, name in pairs(M.GROUP_ROLES) do
382
+ if fn(tr, name, 0, 0) & bit ~= 0 then roles[#roles + 1] = role end
383
+ end
384
+ table.sort(roles)
385
+ return roles
386
+ end
387
+
388
+ --- set_track_group: turn the given roles on (or off with enable = false)
389
+ --- for one group. Roles are checked before anything is written.
390
+ function M.set_track_group(api, tr, params)
391
+ local group = tonumber(params.group)
392
+ if not group or group < 1 or group > 64 or group % 1 ~= 0 then return nil, "group must be 1-64" end
393
+ if type(params.roles) ~= "table" or #params.roles == 0 then return nil, "roles array required" end
394
+ for _, role in ipairs(params.roles) do
395
+ if not M.GROUP_ROLES[role] then
396
+ local names = {}
397
+ for k in pairs(M.GROUP_ROLES) do names[#names + 1] = k end
398
+ table.sort(names)
399
+ return nil, "unknown role '" .. tostring(role) .. "' (use " .. table.concat(names, ", ") .. ")"
400
+ end
401
+ end
402
+ local fn, bit = group_call(api, group)
403
+ local on = params.enable ~= false
404
+ for _, role in ipairs(params.roles) do fn(tr, M.GROUP_ROLES[role], bit, on and bit or 0) end
405
+ return { track = label(api, tr), group = group, roles = M.group_roles(api, tr, group) }
406
+ end
407
+
408
+ M.is_master = is_master
409
+ return M