@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,1378 @@
1
+ -- Edit views for the MCP Bridge status page: what a write changed and what a listing showed, kept on the
2
+ -- command's log entry (`r`). Writes read the state they touch before the handler runs (M.before) and again
3
+ -- after (M.summary); listings summarise their response. Every list is capped so one entry stays a few KB.
4
+ -- Pure Lua over an injected API (M.init), loaded by mcp_bridge.lua and tested through it in wasmoon.
5
+ local M = {}
6
+
7
+ local api, ctx
8
+
9
+ --- ctx: { api, to_db, json_decode, json_decode_array, get_midi_notes, get_midi_take, get_midi_cc, resolve_track }
10
+ function M.init(c)
11
+ ctx = c
12
+ api = c.api
13
+ end
14
+
15
+ local function round2(v)
16
+ if type(v) ~= "number" or v ~= v or v == math.huge or v == -math.huge then return nil end
17
+ return math.floor(v * 100 + 0.5) / 100
18
+ end
19
+ local EDIT_MAX_ROWS = 32
20
+ local EDIT_MAX_POINTS = 200
21
+ local EDIT_MAX_NOTES = 256
22
+ local EDIT_MAX_MARKS = 64
23
+
24
+ local function round3(v)
25
+ if type(v) ~= "number" or v ~= v or v == math.huge or v == -math.huge then return nil end
26
+ return math.floor(v * 1000 + 0.5) / 1000
27
+ end
28
+
29
+ -- Arrays in params arrive as JSON strings
30
+ local function decode_param_list(v)
31
+ if type(v) == "table" then return v end
32
+ if type(v) ~= "string" then return nil end
33
+ for _, decode in ipairs({ ctx.json_decode, ctx.json_decode_array }) do
34
+ local ok, val = pcall(decode, v)
35
+ if ok and type(val) == "table" then return val end
36
+ end
37
+ return nil
38
+ end
39
+
40
+ local function track_named(idx)
41
+ idx = tonumber(idx)
42
+ if idx == nil then return nil end
43
+ local tr = idx == -1 and api.GetMasterTrack(0) or api.GetTrack(0, idx)
44
+ if not tr then return nil end
45
+ if idx == -1 then return tr, "Master" end
46
+ local _, name = api.GetTrackName(tr)
47
+ return tr, name or ("Track " .. (idx + 1))
48
+ end
49
+
50
+ -- Track properties: [info key, scale] (db = volume in dB, pan = −1..1, bool = on/off)
51
+ local TRACK_PROPS = {
52
+ volume = { "D_VOL", "db" }, pan = { "D_PAN", "pan" }, mute = { "B_MUTE", "bool" },
53
+ solo = { "I_SOLO", "bool" }, recordArm = { "I_RECARM", "bool" }, phase = { "B_PHASE", "bool" },
54
+ }
55
+
56
+ local function track_targets(params, cmd_type)
57
+ local out = {}
58
+ if cmd_type == "set_track_property" then
59
+ out[1] = { track = params.trackIndex, prop = params.property }
60
+ else
61
+ for _, t in ipairs(decode_param_list(params.tracks) or {}) do
62
+ for prop in pairs(TRACK_PROPS) do
63
+ if t[prop] ~= nil and #out < EDIT_MAX_ROWS then out[#out + 1] = { track = t.trackIndex, prop = prop } end
64
+ end
65
+ end
66
+ end
67
+ return out
68
+ end
69
+
70
+ local function read_track_values(targets)
71
+ local out = {}
72
+ for i, t in ipairs(targets) do
73
+ local spec = TRACK_PROPS[t.prop]
74
+ local tr, name = track_named(t.track)
75
+ if spec and tr then
76
+ local v = api.GetMediaTrackInfo_Value(tr, spec[1])
77
+ if spec[2] == "db" then v = ctx.to_db(v) elseif spec[2] == "bool" then v = v ~= 0 and 1 or 0 end
78
+ out[i] = { t = name, p = t.prop, v = round2(v) or -150, s = spec[2] }
79
+ end
80
+ end
81
+ return out
82
+ end
83
+
84
+ local function fx_targets(params, cmd_type)
85
+ if cmd_type == "set_fx_parameter" then return { { track = params.trackIndex, fx = params.fxIndex, param = params.paramIndex } } end
86
+ local out = {}
87
+ for _, u in ipairs(decode_param_list(params.updates) or {}) do
88
+ if #out < EDIT_MAX_ROWS then out[#out + 1] = { track = u.trackIndex, fx = u.fxIndex, param = u.paramIndex } end
89
+ end
90
+ return out
91
+ end
92
+
93
+ local function read_fx_values(targets)
94
+ local out = {}
95
+ for i, t in ipairs(targets) do
96
+ local tr, name = track_named(t.track)
97
+ local fx, p = tonumber(t.fx), tonumber(t.param)
98
+ if tr and fx and p then
99
+ local _, fx_name = api.TrackFX_GetFXName(tr, fx)
100
+ local _, p_name = api.TrackFX_GetParamName(tr, fx, p)
101
+ local _, text = api.TrackFX_GetFormattedParamValue(tr, fx, p)
102
+ out[i] = {
103
+ t = name .. " · " .. (fx_name or ("FX " .. (fx + 1))), p = p_name or ("Param " .. (p + 1)),
104
+ v = round3(api.TrackFX_GetParamNormalized(tr, fx, p)), x = text, s = "norm",
105
+ }
106
+ end
107
+ end
108
+ return out
109
+ end
110
+
111
+ -- Rows of before → after for the same targets
112
+ local function change_rows(before, after)
113
+ local rows = {}
114
+ for i, b in pairs(after) do
115
+ local a = before and before[i]
116
+ rows[#rows + 1] = { i = i, t = b.t, p = b.p, s = b.s, a = a and a.v, b = b.v, at = a and a.x, bt = b.x }
117
+ end
118
+ table.sort(rows, function(x, y) return x.i < y.i end)
119
+ for _, r in ipairs(rows) do r.i = nil end
120
+ return { rows = rows }
121
+ end
122
+
123
+ -- Envelopes: values in the unit the envelope reads in
124
+ local function env_of(params)
125
+ local tr = api.GetTrack(0, tonumber(params.trackIndex) or -2)
126
+ local ei = tonumber(params.envelopeIndex)
127
+ if not tr or not ei then return nil end
128
+ return api.GetTrackEnvelope(tr, ei)
129
+ end
130
+
131
+ local function env_unit(env)
132
+ local _, name = api.GetEnvelopeName(env)
133
+ local n = (name or ""):lower()
134
+ if n:find("volume") then return name, "dB" end
135
+ if n:find("pan") or n:find("width") then return name, "pan" end
136
+ if n:find("mute") then return name, "mute" end
137
+ return name, "raw"
138
+ end
139
+
140
+ local function env_points(env)
141
+ local _, unit = env_unit(env)
142
+ local mode = api.GetEnvelopeScalingMode and api.GetEnvelopeScalingMode(env) or 0
143
+ local pts = {}
144
+ for i = 0, api.CountEnvelopePoints(env) - 1 do
145
+ local _, time, value, shape = api.GetEnvelopePoint(env, i)
146
+ local v = value
147
+ if unit == "dB" then
148
+ local amp = api.ScaleFromEnvelopeMode and api.ScaleFromEnvelopeMode(mode, value) or value
149
+ v = amp > 0 and 20 * math.log(amp, 10) or -150
150
+ end
151
+ pts[#pts + 1] = { round3(time) or 0, round2(v) or 0, shape or 0 }
152
+ end
153
+ return pts
154
+ end
155
+
156
+ -- Automation items as blocks [position, length, pool id, pool name] plus each pool's points
157
+ -- ([time from the item start, value, shape]) once, so pooled items draw the same curve.
158
+ local EDIT_MAX_ITEMS = 32
159
+ local AUTOMATION_ITEM_COMMANDS = {
160
+ list_automation_items = true, insert_automation_item = true, set_automation_item = true, delete_automation_item = true,
161
+ }
162
+ local POOL_POINTS = 0x10000000
163
+ local function env_items(env)
164
+ if not api.CountAutomationItems then return nil end
165
+ local n = tonumber((api.CountAutomationItems(env))) or 0
166
+ if n == 0 then return nil end
167
+ local _, unit = env_unit(env)
168
+ local mode = api.GetEnvelopeScalingMode and api.GetEnvelopeScalingMode(env) or 0
169
+ local blocks, pools, seen = {}, {}, {}
170
+ for i = 0, math.min(n, EDIT_MAX_ITEMS) - 1 do
171
+ local function info(parm) return api.GetSetAutomationItemInfo(env, i, parm, 0, false) end
172
+ local pos, pool = info("D_POSITION"), math.floor(info("D_POOL_ID") + 0.5)
173
+ local _, name = api.GetSetAutomationItemInfo_String(env, i, "P_POOL_NAME", "", false)
174
+ blocks[#blocks + 1] = { round3(pos) or 0, round3(info("D_LENGTH")) or 0, pool, name or "" }
175
+ if not seen[pool] then
176
+ seen[pool] = true
177
+ local pts = {}
178
+ for k = 0, math.min(api.CountEnvelopePointsEx(env, i | POOL_POINTS), EDIT_MAX_POINTS) - 1 do
179
+ local _, time, value, shape = api.GetEnvelopePointEx(env, i | POOL_POINTS, k)
180
+ local v = value
181
+ if unit == "dB" then
182
+ local amp = api.ScaleFromEnvelopeMode and api.ScaleFromEnvelopeMode(mode, value) or value
183
+ v = amp > 0 and 20 * math.log(amp, 10) or -150
184
+ end
185
+ pts[#pts + 1] = { round3(time - pos) or 0, round2(v) or 0, shape or 0 }
186
+ end
187
+ pools[#pools + 1] = { pool, pts }
188
+ end
189
+ end
190
+ return { items = blocks, pools = pools, more = math.max(0, n - EDIT_MAX_ITEMS) }
191
+ end
192
+
193
+ -- The points inside [from, to] plus one on each side, so the curve reaches the edges
194
+ local function points_in(pts, from, to)
195
+ local first, last
196
+ for i, p in ipairs(pts) do
197
+ if p[1] >= from and p[1] <= to then first = first or i; last = i end
198
+ end
199
+ if not first then
200
+ for i, p in ipairs(pts) do if p[1] < from then first = i end end
201
+ first = first or 1
202
+ last = math.min(#pts, first + 1)
203
+ end
204
+ local out = {}
205
+ for i = math.max(1, first - 1), math.min(#pts, last + 1) do
206
+ if #out < EDIT_MAX_POINTS then out[#out + 1] = pts[i] end
207
+ end
208
+ return out
209
+ end
210
+
211
+ local function envelope_view(cmd_type, params, data, before)
212
+ local env = env_of(params)
213
+ if not env then return nil end
214
+ local name, unit = env_unit(env)
215
+ local after = env_points(env)
216
+ local from, to
217
+ local function span(t) if t then from = math.min(from or t, t); to = math.max(to or t, t) end end
218
+ if cmd_type == "insert_envelope_point" then span(tonumber(params.time))
219
+ elseif cmd_type == "insert_envelope_points" then
220
+ for _, p in ipairs(decode_param_list(params.points) or {}) do span(tonumber(p.time)) end
221
+ elseif cmd_type == "remove_envelope_points" then span(tonumber(params.timeStart)); span(tonumber(params.timeEnd))
222
+ elseif cmd_type == "delete_envelope_point" then
223
+ -- The handler returns the point it deleted (also one inside an automation item)
224
+ local gone = type(data) == "table" and type(data.deleted) == "table" and data.deleted.time
225
+ local p = before and before[(tonumber(params.pointIndex) or -1) + 1]
226
+ span(tonumber(gone) or (p and p[1]))
227
+ elseif type(data) == "table" and type(data.points) == "table" then
228
+ for _, p in ipairs(data.points) do span(p.time) end
229
+ end
230
+ -- Automation items: the item a call touched (before and after a move), or every item for a listing
231
+ local items = env_items(env)
232
+ local was_items = type(before) == "table" and before.items or nil
233
+ local touched = tonumber(params.automationItemIndex)
234
+ if type(data) == "table" and type(data.automationItem) == "table" then touched = data.automationItem.index end
235
+ if cmd_type == "delete_automation_item" then touched = nil end
236
+ local function span_block(b) if b then span(b[1]); span(b[1] + b[2]) end end
237
+ if AUTOMATION_ITEM_COMMANDS[cmd_type] or touched then
238
+ if touched and items then span_block(items.items[touched + 1]) end
239
+ if was_items and params.automationItemIndex ~= nil then span_block(was_items.items[(tonumber(params.automationItemIndex) or -1) + 1]) end
240
+ if not from then
241
+ for _, b in ipairs(items and items.items or {}) do span_block(b) end
242
+ for _, b in ipairs(was_items and was_items.items or {}) do span_block(b) end
243
+ end
244
+ end
245
+ if not from then
246
+ for _, p in ipairs(after) do span(p[1]) end
247
+ for _, p in ipairs(before or {}) do span(p[1]) end
248
+ end
249
+ from, to = from or 0, to or 0
250
+ local pad = math.max(to - from, 4) * 0.25
251
+ from, to = math.max(0, from - pad), to + pad
252
+ local r = { env = name, unit = unit, from = round2(from), to = round2(to), pts = points_in(after, from, to), count = #after }
253
+ if before then r.was = points_in(before, from, to) end
254
+ if items then
255
+ r.ais, r.pools = items.items, items.pools
256
+ if items.more > 0 then r.aisMore = items.more end
257
+ if touched then r.ai = touched end
258
+ end
259
+ if was_items and cmd_type ~= "list_automation_items" then r.wasAis, r.wasPools = was_items.items, was_items.pools end
260
+ return r
261
+ end
262
+
263
+ -- MIDI: notes as [pitch, start, length, velocity, flag] in beats from the item start; flag 1 = new or changed
264
+ local function midi_notes(params)
265
+ local ok, data = pcall(ctx.get_midi_notes, { trackIndex = params.trackIndex, itemIndex = params.itemIndex })
266
+ if not ok or type(data) ~= "table" or type(data.notes) ~= "table" then return nil end
267
+ local notes = {}
268
+ for _, n in ipairs(data.notes) do
269
+ notes[#notes + 1] = { n.pitch, round3(n.startPosition) or 0, round3(n.duration) or 0, n.velocity }
270
+ end
271
+ return notes
272
+ end
273
+
274
+ local function note_key(n) return n[1] .. ":" .. n[2] .. ":" .. n[3] .. ":" .. n[4] end
275
+
276
+ local function midi_view(params, before)
277
+ local after = midi_notes(params)
278
+ if not after then return nil end
279
+ local _, item = ctx.get_midi_take(params)
280
+ local r = { total = #after }
281
+ if item then
282
+ local pos = api.GetMediaItemInfo_Value(item, "D_POSITION")
283
+ local len = api.GetMediaItemInfo_Value(item, "D_LENGTH")
284
+ r.beats = round3(api.TimeMap2_timeToQN(0, pos + len) - api.TimeMap2_timeToQN(0, pos))
285
+ local num, den = api.TimeMap_GetTimeSigAtTime(0, pos)
286
+ r.ts = { num or 4, den or 4 }
287
+ end
288
+ local had = {}
289
+ for _, n in ipairs(before or {}) do had[note_key(n)] = true end
290
+ local has = {}
291
+ r.notes = {}
292
+ for _, n in ipairs(after) do
293
+ has[note_key(n)] = true
294
+ if #r.notes < EDIT_MAX_NOTES then
295
+ r.notes[#r.notes + 1] = { n[1], n[2], n[3], n[4], (before and not had[note_key(n)]) and 1 or 0 }
296
+ end
297
+ end
298
+ r.more = #after - #r.notes
299
+ if before then
300
+ r.gone = {}
301
+ for _, n in ipairs(before) do
302
+ if not has[note_key(n)] and #r.gone < EDIT_MAX_ROWS then r.gone[#r.gone + 1] = n end
303
+ end
304
+ end
305
+ return r
306
+ end
307
+
308
+ -- MIDI CC as an automation lane: one controller's values (0-127) over the item, in beats, held until the next event.
309
+ -- The controller is the call's ccNumber, the deleted event's, or the item's busiest one.
310
+ local CC_NAMES = { [1] = "Mod wheel", [2] = "Breath", [4] = "Foot", [7] = "Volume", [10] = "Pan", [11] = "Expression", [64] = "Sustain", [74] = "Brightness" }
311
+ local CC_COMMANDS = { get_midi_cc = true, insert_midi_cc = true, delete_midi_cc = true }
312
+
313
+ local function cc_events(params)
314
+ if not ctx.get_midi_cc then return nil end
315
+ local ok, data = pcall(ctx.get_midi_cc, { trackIndex = params.trackIndex, itemIndex = params.itemIndex })
316
+ if not ok or type(data) ~= "table" or type(data.events) ~= "table" then return nil end
317
+ return data.events
318
+ end
319
+
320
+ local function cc_view(cmd_type, params, before)
321
+ local events = cc_events(params) or {}
322
+ local cc = tonumber(params.ccNumber)
323
+ if not cc and cmd_type == "delete_midi_cc" then
324
+ for _, e in ipairs(before or {}) do if e.ccIndex == tonumber(params.ccIndex) then cc = e.ccNumber end end
325
+ end
326
+ if not cc then
327
+ local counts, best = {}, 0
328
+ for _, e in ipairs(events) do
329
+ counts[e.ccNumber] = (counts[e.ccNumber] or 0) + 1
330
+ if counts[e.ccNumber] > best then best, cc = counts[e.ccNumber], e.ccNumber end
331
+ end
332
+ end
333
+ if not cc then return nil end
334
+ local function lane(list)
335
+ local pts = {}
336
+ for _, e in ipairs(list or {}) do
337
+ if e.ccNumber == cc and #pts < EDIT_MAX_POINTS then pts[#pts + 1] = { round3(e.position) or 0, e.value, 1 } end
338
+ end
339
+ table.sort(pts, function(a, b) return a[1] < b[1] end)
340
+ return pts
341
+ end
342
+ local pts, was = lane(events), before and lane(before) or nil
343
+ local from, to = 0, 4
344
+ for _, p in ipairs(pts) do to = math.max(to, p[1]) end
345
+ for _, p in ipairs(was or {}) do to = math.max(to, p[1]) end
346
+ local name = "CC " .. cc .. (CC_NAMES[cc] and (" · " .. CC_NAMES[cc]) or "")
347
+ local others = {}
348
+ for _, e in ipairs(events) do if e.ccNumber ~= cc then others[e.ccNumber] = true end end
349
+ local more = 0
350
+ for _ in pairs(others) do more = more + 1 end
351
+ local r = { env = name, unit = "cc", beats = 1, from = 0, to = round2(math.ceil(to + 1)), pts = pts, count = #pts }
352
+ if more > 0 then r.otherCcs = more end
353
+ if before then r.was = was end
354
+ return r
355
+ end
356
+
357
+ local function midi_stats_view(data)
358
+ if type(data) ~= "table" or type(data.pitchStats) ~= "table" then return nil end
359
+ local gun = {}
360
+ for _, g in ipairs(data.machineGunWarnings or {}) do gun[g.pitch] = g.maxConsecutive end
361
+ local pitches = {}
362
+ for _, p in ipairs(data.pitchStats) do
363
+ if #pitches < EDIT_MAX_ROWS then
364
+ pitches[#pitches + 1] = { p.pitch, p.count, p.minVelocity, p.maxVelocity, p.avgVelocity, gun[p.pitch] or 0 }
365
+ end
366
+ end
367
+ return { total = data.totalNotes, beats = data.durationBeats, pitches = pitches, hist = data.velocityHistogram }
368
+ end
369
+
370
+ -- Timeline: regions [start, end, name, color, index], markers [pos, name, color, index], tempo [pos, bpm, num, den, linear]
371
+ local function hex_color(native)
372
+ native = tonumber(native) or 0
373
+ if native == 0 or not api.ColorFromNative then return "" end
374
+ local r, g, b = api.ColorFromNative(native & 0xFFFFFF)
375
+ return string.format("#%02x%02x%02x", r or 0, g or 0, b or 0)
376
+ end
377
+
378
+ -- A track from an index, "master", a name or "{GUID}" (the same resolution the structure tools use)
379
+ local function track_ref(ref)
380
+ if ref == nil then return nil end
381
+ if type(ref) == "number" or (type(ref) == "string" and ref:match("^%-?%d+$")) then
382
+ local tr = track_named(ref)
383
+ if tr then return tr end
384
+ end
385
+ return ctx.resolve_track and ctx.resolve_track(ref) or nil
386
+ end
387
+
388
+ local function name_of(tr)
389
+ if not tr then return nil end
390
+ if api.GetMasterTrack and tr == api.GetMasterTrack(0) then return "Master" end
391
+ local _, name = api.GetTrackName(tr)
392
+ return name or "Track"
393
+ end
394
+
395
+ -- A stable short id for diffing before and after (GUIDs survive moves and renames)
396
+ local function short_id(guid)
397
+ return type(guid) == "string" and guid:gsub("[{}]", ""):sub(1, 8) or ""
398
+ end
399
+
400
+ -- FX chain: [name, flags (B bypassed, O offline), id, preset]
401
+ local function fx_chain(tr)
402
+ local out = {}
403
+ for i = 0, math.min(api.TrackFX_GetCount(tr) or 0, EDIT_MAX_ROWS) - 1 do
404
+ local _, name = api.TrackFX_GetFXName(tr, i)
405
+ local enabled = not api.TrackFX_GetEnabled or api.TrackFX_GetEnabled(tr, i) ~= false
406
+ local offline = api.TrackFX_GetOffline and api.TrackFX_GetOffline(tr, i) == true
407
+ local preset = ""
408
+ if api.TrackFX_GetPreset then
409
+ local _, p = api.TrackFX_GetPreset(tr, i)
410
+ preset = type(p) == "string" and p or ""
411
+ end
412
+ out[#out + 1] = { name or ("FX " .. (i + 1)), (enabled and "" or "B") .. (offline and "O" or ""), short_id(api.TrackFX_GetFXGUID(tr, i)), preset }
413
+ end
414
+ return out
415
+ end
416
+
417
+ local FX_CHAIN_COMMANDS = {
418
+ add_fx = true, remove_fx = true, move_fx = true, copy_fx = true, set_fx_enabled = true, set_fx_offline = true,
419
+ setup_fx_chain = true, load_fx_chain = true, rename_fx = true, set_fx_preset = true, get_track_properties = true,
420
+ }
421
+
422
+ -- The tracks an FX command touches: its track, and the other track of a move/copy
423
+ local function fx_tracks(params)
424
+ local out = {}
425
+ for _, ref in ipairs({ params.trackIndex, params.toTrackIndex }) do
426
+ local tr = track_ref(ref)
427
+ if tr and #out < 2 and (out[1] == nil or out[1] ~= tr) then out[#out + 1] = tr end
428
+ end
429
+ return out
430
+ end
431
+
432
+ local function fx_chains(params)
433
+ local out = {}
434
+ for i, tr in ipairs(fx_tracks(params)) do out[i] = fx_chain(tr) end
435
+ return out
436
+ end
437
+
438
+ local function fx_chain_view(params, before)
439
+ local chains = {}
440
+ for i, tr in ipairs(fx_tracks(params)) do
441
+ chains[i] = { t = name_of(tr), fx = fx_chain(tr), was = before and before[i] or nil }
442
+ end
443
+ return #chains > 0 and { chains = chains } or nil
444
+ end
445
+
446
+ -- Media items of a track, in the timeline's item shape; `gain` adds the item's gain in dB
447
+ local function track_items(tr, gain)
448
+ local out = {}
449
+ for i = 0, math.min(api.CountTrackMediaItems(tr) or 0, EDIT_MAX_MARKS) - 1 do
450
+ local it = api.GetTrackMediaItem(tr, i)
451
+ local take = api.GetActiveTake and api.GetActiveTake(it)
452
+ out[#out + 1] = {
453
+ round2(api.GetMediaItemInfo_Value(it, "D_POSITION")) or 0, round2(api.GetMediaItemInfo_Value(it, "D_LENGTH")) or 0,
454
+ round2(api.GetMediaItemInfo_Value(it, "D_FADEINLEN")) or 0, round2(api.GetMediaItemInfo_Value(it, "D_FADEOUTLEN")) or 0,
455
+ (take and api.TakeIsMIDI and api.TakeIsMIDI(take)) and 1 or 0, api.GetMediaItemInfo_Value(it, "B_MUTE") ~= 0 and 1 or 0,
456
+ }
457
+ if gain then
458
+ local v = api.GetMediaItemInfo_Value(it, "D_VOL") or 1
459
+ out[#out][7] = v > 0 and round2(20 * math.log(v, 10)) or -150
460
+ end
461
+ end
462
+ return out
463
+ end
464
+
465
+ local ITEM_COMMANDS = {
466
+ move_media_item = true, split_media_item = true, trim_media_item = true, delete_media_item = true,
467
+ set_media_item_properties = true, set_media_items_properties = true, set_item_fades = true, insert_audio_file = true,
468
+ list_midi_items = true,
469
+ }
470
+
471
+ -- The first four tracks the items are on (one lane each), and how many tracks that is in all
472
+ local function item_tracks(params)
473
+ local out, seen, n = {}, {}, 0
474
+ local function add(idx)
475
+ idx = tonumber(idx)
476
+ if idx and not seen[idx] and api.GetTrack(0, idx) then
477
+ seen[idx] = true
478
+ n = n + 1
479
+ if #out < 4 then out[#out + 1] = idx end
480
+ end
481
+ end
482
+ add(params.trackIndex)
483
+ add(params.newTrackIndex)
484
+ for _, it in ipairs(decode_param_list(params.items) or {}) do if type(it) == "table" then add(it.trackIndex) end end
485
+ return out, n
486
+ end
487
+
488
+ local function item_lanes(params)
489
+ local out = {}
490
+ for i, idx in ipairs(item_tracks(params)) do out[i] = track_items(api.GetTrack(0, idx)) end
491
+ return out
492
+ end
493
+
494
+ -- Time selection [start, end] (nil when empty) and edit cursor
495
+ local function time_state()
496
+ local s, e = api.GetSet_LoopTimeRange(false, false, 0, 0, false)
497
+ local sel = (type(s) == "number" and type(e) == "number" and e > s) and { round2(s), round2(e) } or nil
498
+ return { sel = sel, cur = round2(api.GetCursorPosition and api.GetCursorPosition() or 0) or 0 }
499
+ end
500
+ local TIME_COMMANDS = { set_time_selection = true, get_time_selection = true, set_cursor_position = true }
501
+
502
+ -- Routing: [kind (s send, r receive, h hardware), other end, volume dB, pan, flags (M muted)]
503
+ local function routes_of(tr)
504
+ local out = {}
505
+ for _, cat in ipairs({ { 0, "s" }, { -1, "r" }, { 1, "h" } }) do
506
+ for i = 0, (api.GetTrackNumSends(tr, cat[1]) or 0) - 1 do
507
+ if #out < EDIT_MAX_ROWS then
508
+ local other
509
+ if cat[2] == "h" then
510
+ local dst = math.floor(api.GetTrackSendInfo_Value(tr, 1, i, "I_DSTCHAN") or 0)
511
+ local ch = (dst & 0x3FF) + 1
512
+ other = (dst & 1024) ~= 0 and ("Out " .. ch) or ("Out " .. ch .. "/" .. (ch + 1))
513
+ else
514
+ other = name_of(api.GetTrackSendInfo_Value(tr, cat[1], i, cat[2] == "s" and "P_DESTTRACK" or "P_SRCTRACK")) or "?"
515
+ end
516
+ out[#out + 1] = {
517
+ cat[2], other, round2(ctx.to_db(api.GetTrackSendInfo_Value(tr, cat[1], i, "D_VOL"))) or -150,
518
+ round2(api.GetTrackSendInfo_Value(tr, cat[1], i, "D_PAN")) or 0,
519
+ api.GetTrackSendInfo_Value(tr, cat[1], i, "B_MUTE") ~= 0 and "M" or "",
520
+ }
521
+ end
522
+ end
523
+ end
524
+ return out
525
+ end
526
+
527
+ local ROUTING_COMMANDS = { get_track_routing = true, add_send = true, set_send = true, remove_send = true, add_hardware_output = true }
528
+ local function routing_track(params)
529
+ return track_ref(params.trackIndex) or track_ref(params.track) or track_ref(params.from)
530
+ end
531
+
532
+ -- Structure edits redraw the mixer with the ids before and after, so the page can mark added, removed and moved tracks
533
+ local STRUCTURE_COMMANDS = { add_track = true, delete_track = true, move_track = true, set_track_folder = true, set_track_info = true }
534
+
535
+ -- get_fx_parameters: the first parameters of the FX as bars with the plugin's readout
536
+ local function fx_param_rows(params)
537
+ local tr = track_ref(params.trackIndex)
538
+ local fx = tonumber(params.fxIndex)
539
+ if not tr or not fx or not api.TrackFX_GetNumParams then return nil end
540
+ local n = api.TrackFX_GetNumParams(tr, fx) or 0
541
+ local _, fx_name = api.TrackFX_GetFXName(tr, fx)
542
+ local rows = {}
543
+ for p = 0, math.min(n, EDIT_MAX_ROWS) - 1 do
544
+ local _, p_name = api.TrackFX_GetParamName(tr, fx, p)
545
+ local _, text = api.TrackFX_GetFormattedParamValue(tr, fx, p)
546
+ rows[#rows + 1] = { t = fx_name or ("FX " .. (fx + 1)), p = p_name or ("Param " .. (p + 1)), s = "norm", b = round3(api.TrackFX_GetParamNormalized(tr, fx, p)), bt = text }
547
+ end
548
+ return { rows = rows, more = math.max(0, n - #rows) }
549
+ end
550
+
551
+ local function timeline_snapshot()
552
+ local regions, markers, tempo = {}, {}, {}
553
+ local _, nm, nr = api.CountProjectMarkers(0)
554
+ for i = 0, (nm or 0) + (nr or 0) - 1 do
555
+ local _, isrgn, pos, rend, name, idx, color = api.EnumProjectMarkers3(0, i)
556
+ if isrgn and #regions < EDIT_MAX_MARKS then
557
+ regions[#regions + 1] = { round2(pos) or 0, round2(rend) or 0, name or "", hex_color(color), idx }
558
+ elseif not isrgn and #markers < EDIT_MAX_MARKS then
559
+ markers[#markers + 1] = { round2(pos) or 0, name or "", hex_color(color), idx }
560
+ end
561
+ end
562
+ for i = 0, (api.CountTempoTimeSigMarkers(0) or 0) - 1 do
563
+ local _, pos, _, _, bpm, num, den, lin = api.GetTempoTimeSigMarker(0, i)
564
+ if #tempo < EDIT_MAX_MARKS then tempo[#tempo + 1] = { round2(pos) or 0, round2(bpm) or 0, num or -1, den or -1, lin and 1 or 0 } end
565
+ end
566
+ if #tempo == 0 then
567
+ local num, den = api.TimeMap_GetTimeSigAtTime(0, 0)
568
+ tempo[1] = { 0, round2(api.Master_GetTempo()) or 120, num or 4, den or 4, 0 }
569
+ end
570
+ return { regions = regions, markers = markers, tempo = tempo }
571
+ end
572
+
573
+ local function project_length(t, items)
574
+ local len = api.GetProjectLength and api.GetProjectLength(0) or 0
575
+ for _, r in ipairs(t.regions) do len = math.max(len, r[2]) end
576
+ for _, m in ipairs(t.markers) do len = math.max(len, m[1]) end
577
+ for _, m in ipairs(t.tempo) do len = math.max(len, m[1]) end
578
+ for _, it in ipairs(items or {}) do len = math.max(len, it[1] + it[2]) end
579
+ return round2(len) or 0
580
+ end
581
+
582
+ local function timeline_view(cmd_type, data, before)
583
+ local r = timeline_snapshot()
584
+ if cmd_type == "list_media_items" and type(data) == "table" and type(data.items) == "table" then
585
+ r.items = {}
586
+ for _, it in ipairs(data.items) do
587
+ if #r.items < EDIT_MAX_MARKS then
588
+ r.items[#r.items + 1] = {
589
+ round2(it.position) or 0, round2(it.length) or 0, round2(it.fadeInLength) or 0, round2(it.fadeOutLength) or 0,
590
+ it.isMidi and 1 or 0, it.muted and 1 or 0,
591
+ }
592
+ end
593
+ end
594
+ end
595
+ r.len = project_length(r, r.items)
596
+ if before then
597
+ r.was = before
598
+ r.len = math.max(r.len, project_length(before))
599
+ end
600
+ return r
601
+ end
602
+
603
+ -- Item edits: the timeline with one lane per touched track, each with its items before the edit
604
+ local function item_view(params, before)
605
+ local r = timeline_snapshot()
606
+ r.lanes = {}
607
+ local all = {}
608
+ for i, idx in ipairs(item_tracks(params)) do
609
+ local tr = api.GetTrack(0, idx)
610
+ local lane = { t = name_of(tr), items = track_items(tr), was = before and before[i] or nil }
611
+ r.lanes[i] = lane
612
+ for _, it in ipairs(lane.items) do all[#all + 1] = it end
613
+ for _, it in ipairs(lane.was or {}) do all[#all + 1] = it end
614
+ end
615
+ r.len = project_length(r, all)
616
+ return r
617
+ end
618
+
619
+ -- Arrangement edits: the whole timeline before and after, a lane for up to four tracks the
620
+ -- edit touches, and the span it worked on (copied from, opened, cut) and where it copied to
621
+ local ARRANGE = {}
622
+ ARRANGE.OPS = { copy_media_items = "copy", insert_time = "insert", remove_time = "remove", copy_section = "section" }
623
+
624
+ -- The section a copy_section call copies, from a region index or start/end
625
+ function ARRANGE.section(params)
626
+ local idx = tonumber(params.regionIndex)
627
+ if idx == nil then return tonumber(params.start), tonumber(params["end"]) end
628
+ local _, nm, nr = api.CountProjectMarkers(0)
629
+ for i = 0, (nm or 0) + (nr or 0) - 1 do
630
+ local _, isrgn, pos, rend, _, num = api.EnumProjectMarkers3(0, i)
631
+ if isrgn and num == idx then return pos, rend end
632
+ end
633
+ end
634
+
635
+ -- Track indexes for the lanes (at most four) and how many tracks the edit touches
636
+ function ARRANGE.tracks(cmd_type, params)
637
+ if cmd_type == "copy_media_items" or cmd_type == "process_items" then
638
+ return item_tracks(params)
639
+ end
640
+ local only = cmd_type == "copy_section" and decode_param_list(params.tracks)
641
+ local s, e
642
+ if cmd_type == "insert_time" then s, e = tonumber(params.position), math.huge
643
+ elseif cmd_type == "remove_time" then s, e = tonumber(params.start), math.huge
644
+ else s, e = ARRANGE.section(params) end
645
+ if not s or not e then return {}, 0 end
646
+ local out, n = {}, 0
647
+ local function consider(t)
648
+ local tr = api.GetTrack(0, t)
649
+ if not tr then return end
650
+ for i = 0, (api.CountTrackMediaItems(tr) or 0) - 1 do
651
+ local it = api.GetTrackMediaItem(tr, i)
652
+ local pos = api.GetMediaItemInfo_Value(it, "D_POSITION")
653
+ if pos < e and pos + api.GetMediaItemInfo_Value(it, "D_LENGTH") > s then
654
+ n = n + 1
655
+ if #out < 4 then out[#out + 1] = t end
656
+ return
657
+ end
658
+ end
659
+ end
660
+ if type(only) == "table" then
661
+ for _, t in ipairs(only) do if tonumber(t) then consider(tonumber(t)) end end
662
+ else
663
+ for t = 0, (api.CountTracks(0) or 0) - 1 do consider(t) end
664
+ end
665
+ return out, n
666
+ end
667
+
668
+ function ARRANGE.before(cmd_type, params)
669
+ local idx, n = ARRANGE.tracks(cmd_type, params)
670
+ local gain = cmd_type == "process_items" and params.operation == "normalize"
671
+ local b = { idx = idx, n = n, tl = timeline_snapshot(), lanes = {} }
672
+ for i, t in ipairs(idx) do b.lanes[i] = track_items(api.GetTrack(0, t), gain) end
673
+ if cmd_type == "copy_media_items" then
674
+ -- The sources' span, read before the copies land
675
+ local first, last
676
+ for _, ref in ipairs(decode_param_list(params.items) or {}) do
677
+ local tr = type(ref) == "table" and api.GetTrack(0, tonumber(ref.trackIndex) or -2)
678
+ local it = tr and api.GetTrackMediaItem(tr, tonumber(ref.itemIndex) or -1)
679
+ if it then
680
+ local pos = api.GetMediaItemInfo_Value(it, "D_POSITION")
681
+ first = math.min(first or pos, pos)
682
+ last = math.max(last or pos, pos + api.GetMediaItemInfo_Value(it, "D_LENGTH"))
683
+ end
684
+ end
685
+ if first then b.span = { round2(first), round2(last) } end
686
+ end
687
+ return b
688
+ end
689
+
690
+ function ARRANGE.view(cmd_type, params, data, before)
691
+ if type(before) ~= "table" or type(data) ~= "table" then return nil end
692
+ local r = timeline_snapshot()
693
+ r.was = before.tl
694
+ r.op = ARRANGE.OPS[cmd_type] or params.operation
695
+ local gain = cmd_type == "process_items" and params.operation == "normalize"
696
+ r.lanes = {}
697
+ local all = {}
698
+ for i, t in ipairs(before.idx) do
699
+ local tr = api.GetTrack(0, t)
700
+ if tr then
701
+ local lane = { t = name_of(tr), items = track_items(tr, gain), was = before.lanes[i] }
702
+ r.lanes[#r.lanes + 1] = lane
703
+ for _, it in ipairs(lane.items) do all[#all + 1] = it end
704
+ for _, it in ipairs(lane.was) do all[#all + 1] = it end
705
+ end
706
+ end
707
+ if before.n > #r.lanes then r.moreTracks = before.n - #r.lanes end
708
+ local num = function(v) return tonumber(v) end
709
+ if cmd_type == "insert_time" and num(data.position) and num(data.length) then
710
+ r.span = { data.position, round2(data.position + data.length) }
711
+ elseif cmd_type == "remove_time" and num(data.start) then
712
+ r.span = { data.start, data["end"] }
713
+ elseif cmd_type == "copy_section" and num(data.start) then
714
+ r.span = { data.start, data["end"] }
715
+ -- An insert at or before the section moved it along
716
+ if data.inserted and data.position <= data.start then r.span = { round2(data.start + data.length), round2(data["end"] + data.length) } end
717
+ r.to = { data.position, round2(data.position + data.length) }
718
+ elseif cmd_type == "copy_media_items" then
719
+ r.span = before.span
720
+ local first, last
721
+ for _, c in ipairs(type(data.copies) == "table" and data.copies or {}) do
722
+ first = math.min(first or c.position, c.position)
723
+ last = math.max(last or c.position, c.position + c.length)
724
+ end
725
+ if first then r.to = { round2(first), round2(last) } end
726
+ end
727
+ r.len = math.max(project_length(r, all), project_length(before.tl))
728
+ for _, v in ipairs({ r.span and r.span[2], r.to and r.to[2] }) do r.len = math.max(r.len, v or 0) end
729
+ r.len = round2(r.len) or 0
730
+ return r
731
+ end
732
+
733
+ -- Time selection and cursor on the timeline, with where they were before a write
734
+ local function time_view(before)
735
+ local r = timeline_snapshot()
736
+ local now = time_state()
737
+ r.sel, r.cur = now.sel, now.cur
738
+ if before then r.wasSel, r.wasCur = before.sel, before.cur end
739
+ local len = project_length(r)
740
+ for _, v in ipairs({ r.cur, r.wasCur, r.sel and r.sel[2], r.wasSel and r.wasSel[2] }) do len = math.max(len, v or 0) end
741
+ r.len = round2(len) or 0
742
+ return r
743
+ end
744
+
745
+ -- Mixer: [name, indent, color, volume dB, pan, flags]; flags M mute, S solo, R armed, F folder
746
+ local function mixer_row(tr, name, indent)
747
+ local flags = (api.GetMediaTrackInfo_Value(tr, "B_MUTE") ~= 0 and "M" or "")
748
+ .. (api.GetMediaTrackInfo_Value(tr, "I_SOLO") ~= 0 and "S" or "")
749
+ .. (api.GetMediaTrackInfo_Value(tr, "I_RECARM") ~= 0 and "R" or "")
750
+ .. (api.GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH") == 1 and "F" or "")
751
+ local color = api.GetMediaTrackInfo_Value(tr, "I_CUSTOMCOLOR")
752
+ return {
753
+ name, indent, (color and color & 0x1000000 ~= 0) and hex_color(color) or "",
754
+ round2(ctx.to_db(api.GetMediaTrackInfo_Value(tr, "D_VOL"))) or -150, round2(api.GetMediaTrackInfo_Value(tr, "D_PAN")) or 0, flags,
755
+ }
756
+ end
757
+
758
+ -- Every track's row and id, with folder indent (structure edits diff the whole project)
759
+ local function mixer_all()
760
+ local rows, ids, level = {}, {}, 0
761
+ for i = 0, api.CountTracks(0) - 1 do
762
+ local tr = api.GetTrack(0, i)
763
+ local _, name = api.GetTrackName(tr)
764
+ rows[#rows + 1] = mixer_row(tr, name or ("Track " .. (i + 1)), level)
765
+ ids[#ids + 1] = short_id(api.GetTrackGUID(tr))
766
+ level = math.max(0, level + (api.GetMediaTrackInfo_Value(tr, "I_FOLDERDEPTH") or 0))
767
+ end
768
+ return rows, ids
769
+ end
770
+
771
+ local function same_row(a, b)
772
+ if not a or not b or #a ~= #b then return false end
773
+ for k = 1, #a do if a[k] ~= b[k] then return false end end
774
+ return true
775
+ end
776
+
777
+ local function slice(list, from, count)
778
+ local out = {}
779
+ for k = from + 1, math.min(#list, from + count) do out[#out + 1] = list[k] end
780
+ return out
781
+ end
782
+
783
+ local function mixer_view(before)
784
+ local rows, ids = mixer_all()
785
+ local r = { total = #rows }
786
+ local master = api.GetMasterTrack(0)
787
+ if master then r.master = mixer_row(master, "Master", 0) end
788
+ local from = 0
789
+ if before then
790
+ -- A big project only fits a window of rows: start it just above the first row that differs
791
+ local first = 1
792
+ while first <= math.max(#ids, #before.ids)
793
+ and ids[first] == before.ids[first] and same_row(rows[first], before.tracks[first]) do
794
+ first = first + 1
795
+ end
796
+ if math.max(#rows, #before.tracks) > EDIT_MAX_MARKS then from = math.max(0, first - 1 - 4) end
797
+ r.ids = slice(ids, from, EDIT_MAX_MARKS)
798
+ r.was = { tracks = slice(before.tracks, from, EDIT_MAX_MARKS), ids = slice(before.ids, from, EDIT_MAX_MARKS) }
799
+ if from > 0 then r.from = from end
800
+ end
801
+ r.tracks = slice(rows, from, EDIT_MAX_MARKS)
802
+ return r
803
+ end
804
+
805
+ -- run_plan: the ops in order, as [op, what]
806
+ local function plan_view(params, data)
807
+ local ops = decode_param_list(params.ops)
808
+ if type(ops) ~= "table" then return nil end
809
+ local rows = {}
810
+ for _, op in ipairs(ops) do
811
+ if #rows < EDIT_MAX_ROWS and type(op) == "table" then
812
+ local target = op.track or op.to or op.target or op.name or ""
813
+ local what = tostring(target)
814
+ if op.fxName or op.fx then what = what .. " · " .. tostring(op.fxName or op.fx) end
815
+ if op.from then what = tostring(op.from) .. " → " .. what end
816
+ if op.action then what = tostring(op.action) end
817
+ rows[#rows + 1] = { tostring(op.op or "?"), what }
818
+ end
819
+ end
820
+ return { ops = rows, total = #ops, dryRun = params.dryRun == true or nil, applied = type(data) == "table" and data.applied or nil }
821
+ end
822
+
823
+ local TIMELINE_WRITES = {
824
+ add_marker = true, add_region = true, edit_marker = true, edit_region = true, delete_marker = true, delete_region = true,
825
+ add_tempo_marker = true, edit_tempo_marker = true, delete_tempo_marker = true, set_project_tempo = true,
826
+ }
827
+ local ENVELOPE_WRITES = {
828
+ insert_envelope_point = true, insert_envelope_points = true, delete_envelope_point = true,
829
+ remove_envelope_points = true, clear_envelope = true, edit_envelope_points = true,
830
+ }
831
+ local MIDI_WRITES = {
832
+ insert_midi_note = true, insert_midi_notes = true, edit_midi_note = true, edit_midi_notes = true, delete_midi_note = true,
833
+ quantize_midi = true, transform_midi = true,
834
+ }
835
+
836
+ -- Measurements: numbers rounded to 0.01 so the feed stays small
837
+ local SPECTRUM_BANDS = 48
838
+ local RESULT_FIELDS = {
839
+ read_track_meters = { "peakL", "peakR", "rmsL", "rmsR" },
840
+ read_track_lufs = { "integrated", "shortTerm", "momentary", "truePeakL", "truePeakR", "duration" },
841
+ read_track_correlation = { "correlation", "stereoWidth", "midLevel", "sideLevel" },
842
+ read_track_crest = { "crestFactor", "peakLevel", "rmsLevel" },
843
+ read_track_spectrum = { "sampleRate", "fftSize", "peakDb", "rmsDb" },
844
+ render_track_to_wav = { "durationSeconds", "sampleRate" },
845
+ }
846
+
847
+ -- Loudest bin per log-spaced band from 20 Hz to 20 kHz; a band narrower than one bin takes the bin it falls in
848
+ local function spectrum_bands(data)
849
+ local bins, res = data.bins, data.frequencyResolution
850
+ if type(bins) ~= "table" or #bins == 0 or type(res) ~= "number" or res <= 0 then return nil end
851
+ local bands = {}
852
+ for b = 0, SPECTRUM_BANDS - 1 do
853
+ local f0 = 20 * 1000 ^ (b / SPECTRUM_BANDS)
854
+ local f1 = 20 * 1000 ^ ((b + 1) / SPECTRUM_BANDS)
855
+ local i0 = math.max(1, math.floor(f0 / res) + 1)
856
+ local i1 = math.min(#bins, math.max(i0, math.ceil(f1 / res)))
857
+ local top
858
+ for i = i0, i1 do
859
+ local v = bins[i]
860
+ if type(v) == "number" and (top == nil or v > top) then top = v end
861
+ end
862
+ bands[#bands + 1] = round2(top) or -150
863
+ end
864
+ return bands
865
+ end
866
+
867
+ -- Min/max per slice of a rendered WAV (all channels), read while the file still exists: the server deletes it
868
+ -- once it has the response, and the log entry is recorded before the response is written
869
+ local WAVE_BUCKETS = 120
870
+ local WAVE_MAX_SAMPLES = 2000000 -- longer renders are read with a stride
871
+ local WAV_SAMPLE = { [1] = { [16] = { "<i2", 32768 }, [24] = { "<i3", 8388608 }, [32] = { "<i4", 2147483648 } },
872
+ [3] = { [32] = { "<f", 1 }, [64] = { "<d", 1 } } }
873
+
874
+ local function wav_waveform(path)
875
+ if type(path) ~= "string" then return nil end
876
+ local f = io.open(path, "rb")
877
+ if not f then return nil end
878
+ local s = f:read("a")
879
+ f:close()
880
+ if not s or #s < 44 or s:sub(1, 4) ~= "RIFF" or s:sub(9, 12) ~= "WAVE" then return nil end
881
+ local pos, tag, channels, bits, data_pos, data_len = 13
882
+ while pos + 8 <= #s do
883
+ local id, len = s:sub(pos, pos + 3), string.unpack("<I4", s, pos + 4)
884
+ if id == "fmt " and len >= 16 then
885
+ tag, channels = string.unpack("<I2I2", s, pos + 8)
886
+ bits = string.unpack("<I2", s, pos + 22)
887
+ if tag == 0xFFFE and len >= 26 then tag = string.unpack("<I2", s, pos + 32) end -- WAVE_FORMAT_EXTENSIBLE
888
+ elseif id == "data" then
889
+ data_pos, data_len = pos + 8, math.min(len, #s - pos - 7)
890
+ break
891
+ end
892
+ pos = pos + 8 + len + (len % 2)
893
+ end
894
+ local spec = tag and WAV_SAMPLE[tag] and WAV_SAMPLE[tag][bits]
895
+ if not spec or not data_pos or not channels or channels < 1 then return nil end
896
+ local fmt, scale, width = spec[1], spec[2], bits // 8
897
+ local frame_bytes = width * channels
898
+ local frames = data_len // frame_bytes
899
+ if frames < 1 then return nil end
900
+ local stride = math.max(1, math.ceil(frames * channels / WAVE_MAX_SAMPLES))
901
+ local hi, lo = {}, {}
902
+ for b = 1, WAVE_BUCKETS do hi[b], lo[b] = 0, 0 end
903
+ local peak, sum, count, clipped = 0, 0, 0, 0
904
+ for fr = 0, frames - 1, stride do
905
+ local b = fr * WAVE_BUCKETS // frames + 1
906
+ local at = data_pos + fr * frame_bytes
907
+ for _ = 1, channels do
908
+ local v = string.unpack(fmt, s, at) / scale
909
+ at = at + width
910
+ if v > hi[b] then hi[b] = v end
911
+ if v < lo[b] then lo[b] = v end
912
+ local a = math.abs(v)
913
+ if a > peak then peak = a end
914
+ if a >= 0.999 then clipped = clipped + 1 end
915
+ sum = sum + v * v
916
+ count = count + 1
917
+ end
918
+ end
919
+ for b = 1, WAVE_BUCKETS do hi[b], lo[b] = round2(hi[b]), round2(lo[b]) end
920
+ local db = function(v) return v > 0 and round2(20 * math.log(v, 10)) or -150 end
921
+ return { hi = hi, lo = lo, peakDb = db(peak), rmsDb = db(math.sqrt(sum / count)), clipped = clipped, channels = channels }
922
+ end
923
+
924
+ -- Mixing depth views: built from the handler's own result, which carries the state before
925
+ local MIX = {}
926
+
927
+ MIX.MODES = { trim = 0, read = 1, touch = 2, write = 3, latch = 4, latch_preview = 5 }
928
+
929
+ function MIX.automation(data)
930
+ local rows = {}
931
+ for i, t in ipairs(data.tracks or {}) do
932
+ if i > EDIT_MAX_ROWS then break end
933
+ rows[#rows + 1] = { t = t.name, p = "Automation", s = "bool",
934
+ a = MIX.MODES[t.before] or 0, b = MIX.MODES[t.mode] or 0, at = t.before, bt = t.mode }
935
+ end
936
+ if data.globalOverrideBefore then
937
+ rows[#rows + 1] = { t = "All tracks", p = "Global override", s = "bool",
938
+ a = data.globalOverrideBefore == data.globalOverride and 1 or 0, b = 1,
939
+ at = data.globalOverrideBefore, bt = data.globalOverride }
940
+ end
941
+ return { rows = rows }
942
+ end
943
+
944
+ -- One modulated parameter in the page's compact shape (nil when it has none)
945
+ function MIX.mod_row(m)
946
+ if type(m) ~= "table" or m.enabled == false then return nil end
947
+ local r = { p = m.name or ("Param " .. tostring(m.paramIndex)), base = round2(m.baseline) or 0, v = round2(m.value) or 0 }
948
+ if m.lfo then
949
+ r.lfo = { m.lfo.shape, round2(m.lfo.speed) or 0, round2(m.lfo.strength) or 0, round2(m.lfo.phase) or 0,
950
+ m.lfo.direction or 1, m.lfo.tempoSync and 1 or 0 }
951
+ end
952
+ if m.audio then
953
+ r.acs = { round2(m.audio.attackMs) or 0, round2(m.audio.releaseMs) or 0, round2(m.audio.minDb) or 0,
954
+ round2(m.audio.maxDb) or 0, round2(m.audio.strength) or 0, m.audio.channel or 1, m.audio.direction or 1 }
955
+ end
956
+ if m.link then
957
+ local src = (m.link.fxName or ("FX " .. tostring(m.link.fxIndex))) .. " / " .. (m.link.paramName or ("param " .. tostring(m.link.paramIndex)))
958
+ r.link = { src, round2(m.link.scale) or 1, round2(m.link.offset) or 0 }
959
+ end
960
+ return r
961
+ end
962
+
963
+ function MIX.modulation(cmd_type, data)
964
+ if type(data) ~= "table" then return nil end
965
+ local rows = {}
966
+ if cmd_type == "set_fx_param_modulation" then
967
+ local now = MIX.mod_row(data.modulation) or { p = (data.before and data.before.name) or ("Param " .. tostring(data.paramIndex)), off = 1 }
968
+ now.was = MIX.mod_row(data.before) or false
969
+ rows[1] = now
970
+ else
971
+ for i, m in ipairs(data.modulated or {}) do
972
+ if i > EDIT_MAX_ROWS then break end
973
+ rows[#rows + 1] = MIX.mod_row(m)
974
+ end
975
+ end
976
+ return { fx = data.fxName or "", rows = rows, total = data.modulated and #data.modulated or nil }
977
+ end
978
+
979
+ function MIX.pin_lists(side)
980
+ local out = {}
981
+ for i, pin in ipairs(side or {}) do
982
+ if i > EDIT_MAX_ROWS then break end
983
+ out[i] = pin.channels or {}
984
+ end
985
+ return out
986
+ end
987
+
988
+ function MIX.pins(data)
989
+ if type(data) ~= "table" or not data.inputs then return nil end
990
+ local r = { fx = data.fxName or "", ch = data.trackChannels or 2, ["in"] = MIX.pin_lists(data.inputs), out = MIX.pin_lists(data.outputs) }
991
+ local names = {}
992
+ for i, pin in ipairs(data.inputs) do if i <= EDIT_MAX_ROWS then names[i] = pin.name or "" end end
993
+ r.names = names
994
+ if data.before then
995
+ r.chWas = data.before.trackChannels
996
+ r.inWas = MIX.pin_lists(data.before.inputs)
997
+ r.outWas = MIX.pin_lists(data.before.outputs)
998
+ end
999
+ return r
1000
+ end
1001
+
1002
+ -- Grid lines inside an item, seconds from its start (at most EDIT_MAX_POINTS)
1003
+ function MIX.grid_lines(pos, len, grid)
1004
+ if not grid or grid <= 0 or not api.TimeMap2_timeToQN then return nil end
1005
+ local lines = {}
1006
+ local qn = math.ceil(api.TimeMap2_timeToQN(0, pos) / grid - 1e-9) * grid
1007
+ while #lines < EDIT_MAX_POINTS do
1008
+ local t = api.TimeMap2_QNToTime(0, qn) - pos
1009
+ if t > len + 1e-9 then break end
1010
+ lines[#lines + 1] = round2(t)
1011
+ qn = qn + grid
1012
+ end
1013
+ return lines
1014
+ end
1015
+
1016
+ function MIX.stretch(cmd_type, data)
1017
+ if type(data) ~= "table" then return nil end
1018
+ if cmd_type == "align_audio_to_guide" then
1019
+ return { len = data.length, moves = data.moves or {}, more = data.more or 0, guide = true,
1020
+ off = { data.offsetBefore and data.offsetBefore.avgMs or 0, data.offsetAfter and data.offsetAfter.avgMs or 0 } }
1021
+ end
1022
+ if cmd_type == "quantize_audio" then
1023
+ return { len = data.length, moves = data.moves or {}, more = data.more or 0, grid = MIX.grid_lines(data.position or 0, data.length or 0, data.grid),
1024
+ off = { data.offsetBefore and data.offsetBefore.avgMs or 0, data.offsetAfter and data.offsetAfter.avgMs or 0 } }
1025
+ end
1026
+ local marks, was = {}, {}
1027
+ for i, m in ipairs(data.markers or {}) do if i <= EDIT_MAX_POINTS then marks[#marks + 1] = m.position end end
1028
+ for i, m in ipairs(data.before or {}) do if i <= EDIT_MAX_POINTS then was[#was + 1] = m.position end end
1029
+ return { len = data.length, marks = marks, was = was }
1030
+ end
1031
+
1032
+ function MIX.freeze(cmd_type, data)
1033
+ if type(data) ~= "table" then return nil end
1034
+ local rows = {}
1035
+ for i, t in ipairs(data.tracks or {}) do
1036
+ if i > EDIT_MAX_ROWS then break end
1037
+ local b = t.before or {}
1038
+ local label = cmd_type == "freeze_tracks" and ("frozen · " .. tostring(data.mode)) or (t.frozen > 0 and ("frozen · " .. t.frozen .. " layers") or "live")
1039
+ rows[#rows + 1] = { t = t.name, p = "Freeze", s = "bool", a = (b.frozen or 0) > 0 and 1 or 0, b = (t.frozen or 0) > 0 and 1 or 0,
1040
+ at = (b.frozen or 0) > 0 and "frozen" or "live", bt = label }
1041
+ rows[#rows + 1] = { t = t.name, p = "FX", s = "norm", a = math.min(1, (b.fx or 0) / 16), b = math.min(1, (t.fx or 0) / 16),
1042
+ at = tostring(b.fx or 0), bt = tostring(t.fx or 0) }
1043
+ end
1044
+ for i, it in ipairs(data.items or {}) do
1045
+ if i > EDIT_MAX_ROWS then break end
1046
+ local b = it.before or {}
1047
+ rows[#rows + 1] = { t = "Track " .. ((it.trackIndex or 0) + 1) .. " · item " .. ((it.itemIndex or 0) + 1), p = "Active take", s = "norm",
1048
+ a = math.min(1, (b.takes or 1) / 8), b = math.min(1, (it.takes or 1) / 8), at = b.activeTake or "", bt = it.activeTake or "" }
1049
+ end
1050
+ return { rows = rows }
1051
+ end
1052
+
1053
+ function MIX.monitor_slots()
1054
+ local m = api.GetMasterTrack(0)
1055
+ local out = {}
1056
+ if not api.TrackFX_GetRecCount then return out end
1057
+ for i = 0, math.min(api.TrackFX_GetRecCount(m), EDIT_MAX_ROWS) - 1 do
1058
+ local idx = 0x1000000 + i
1059
+ local _, name = api.TrackFX_GetFXName(m, idx)
1060
+ local enabled = api.TrackFX_GetEnabled(m, idx) ~= false
1061
+ local offline = api.TrackFX_GetOffline and api.TrackFX_GetOffline(m, idx) == true
1062
+ out[#out + 1] = { name or ("FX " .. (i + 1)), (enabled and "" or "B") .. (offline and "O" or ""), short_id(api.TrackFX_GetFXGUID(m, idx)), "" }
1063
+ end
1064
+ return out
1065
+ end
1066
+
1067
+ MIX.MONITOR = { list_monitoring_fx = true, add_monitoring_fx = true, set_monitoring_fx = true, remove_monitoring_fx = true }
1068
+
1069
+ -- Render results: one row per file written, with a few waveform slices read through REAPER's peak API
1070
+ -- (any format REAPER reads). At most RENDER_MAX_FILES files get a row; the rest are counted.
1071
+ local RENDER_MAX_FILES = 8
1072
+ -- Waveforms are built on REAPER's main thread after the render already blocked it: once this much
1073
+ -- time went to them in one call, the remaining files get rows without a waveform
1074
+ local RENDER_PEAKS_BUDGET_S = 1.0
1075
+ local RENDER_SLICES = 60
1076
+
1077
+ -- The .reapeaks file REAPER would build for path (nil when it cannot say)
1078
+ local function peak_file(path)
1079
+ if not api.GetPeakFileName then return nil end
1080
+ local a, b = api.GetPeakFileName(path, "")
1081
+ local name = type(b) == "string" and b ~= "" and b or a
1082
+ return type(name) == "string" and name ~= "" and name or nil
1083
+ end
1084
+ local function exists(path)
1085
+ local f = path and io.open(path, "rb")
1086
+ if f then f:close() end
1087
+ return f ~= nil
1088
+ end
1089
+
1090
+ local function file_peaks(path)
1091
+ if not (api.PCM_Source_CreateFromFile and api.PCM_Source_GetPeaks and api.new_array) then return nil end
1092
+ local src = api.PCM_Source_CreateFromFile(path)
1093
+ if not src then return nil end
1094
+ -- Peaks built only for this view are removed again, so no .reapeaks lands beside a delivered file
1095
+ local pk = peak_file(path)
1096
+ local had_peaks = exists(pk)
1097
+ local ok, hi, lo = pcall(function()
1098
+ local len = api.GetMediaSourceLength(src)
1099
+ if type(len) ~= "number" or len <= 0 then return nil end
1100
+ local ch = math.min(2, math.max(1, math.floor(api.GetMediaSourceNumChannels(src) or 1)))
1101
+ if api.PCM_Source_BuildPeaks and api.PCM_Source_BuildPeaks(src, 0) ~= 0 then
1102
+ local guard = 0
1103
+ while api.PCM_Source_BuildPeaks(src, 1) ~= 0 and guard < 1000000 do guard = guard + 1 end
1104
+ api.PCM_Source_BuildPeaks(src, 2)
1105
+ end
1106
+ local n = RENDER_SLICES
1107
+ local buf = api.new_array(n * ch * 2)
1108
+ -- The buffer holds n * ch maxima, then n * ch minima, channels interleaved
1109
+ local got = math.floor(api.PCM_Source_GetPeaks(src, n / len, 0, ch, n, 0, buf)) & 0xFFFFF
1110
+ if got <= 0 then return nil end
1111
+ local hi, lo = {}, {}
1112
+ for i = 0, math.min(got, n) - 1 do
1113
+ local mx, mn = 0, 0
1114
+ for c = 1, ch do
1115
+ local a, b = buf[i * ch + c], buf[n * ch + i * ch + c]
1116
+ if a and a > mx then mx = a end
1117
+ if b and b < mn then mn = b end
1118
+ end
1119
+ hi[#hi + 1], lo[#lo + 1] = round2(mx), round2(mn)
1120
+ end
1121
+ return hi, lo
1122
+ end)
1123
+ api.PCM_Source_Destroy(src)
1124
+ if pk and not had_peaks and exists(pk) then os.remove(pk) end
1125
+ if ok and hi then return hi, lo end
1126
+ end
1127
+
1128
+ local function base_name(path) return (tostring(path):match("([^/\\]+)$")) or tostring(path) end
1129
+
1130
+ -- [name, duration s, LUFS-I, true peak, sample peak, hi[], lo[]] per file
1131
+ local function render_rows(files, analysis, budget)
1132
+ local by_file = {}
1133
+ for _, a in ipairs(analysis or {}) do by_file[a.file] = a end
1134
+ local rows = {}
1135
+ for _, f in ipairs(files or {}) do
1136
+ if budget.left <= 0 then budget.more = budget.more + 1 else
1137
+ budget.left = budget.left - 1
1138
+ local a = by_file[f] or {}
1139
+ local hi, lo
1140
+ local clock = api.time_precise
1141
+ if not clock or clock() - budget.started < RENDER_PEAKS_BUDGET_S then hi, lo = file_peaks(f) end
1142
+ rows[#rows + 1] = { base_name(f), round2(a.durationSeconds) or -1, round2(a.lufsIntegrated) or -150, round2(a.truePeak) or -150,
1143
+ round2(a.samplePeak) or -150, hi or {}, lo or {} }
1144
+ end
1145
+ end
1146
+ return rows
1147
+ end
1148
+
1149
+ local function render_view(cmd_type, data)
1150
+ if type(data) ~= "table" then return nil end
1151
+ local budget = { left = RENDER_MAX_FILES, more = 0, started = api.time_precise and api.time_precise() or 0 }
1152
+ local groups = {}
1153
+ if cmd_type == "render_versions" then
1154
+ for _, v in ipairs(data.versions or {}) do
1155
+ groups[#groups + 1] = { v = tostring(v.name), m = v.muted or {}, f = render_rows(v.files, v.analysis, budget) }
1156
+ end
1157
+ else
1158
+ groups[1] = { v = "", m = {}, f = render_rows(data.files, data.analysis, budget) }
1159
+ end
1160
+ local r = { fmt = data.format or "", src = data.source or "master", groups = groups }
1161
+ if data.bitDepth then r.bd = tostring(data.bitDepth) end
1162
+ if data.mp3Bitrate then r.kbps = data.mp3Bitrate end
1163
+ if data.normalize then r.norm, r.nt = data.normalize, round2(data.normalizeTarget) end
1164
+ if data.limitCeiling then r.lim, r.tp = round2(data.limitCeiling), data.limitTruePeak == true end
1165
+ if budget.more > 0 then r.more = budget.more end
1166
+ return r
1167
+ end
1168
+
1169
+ -- Recording: the global mode and one row per track [name, armed 0/1, input, monitor, mode]
1170
+ local RECORD_COMMANDS = { get_record_setup = true, set_track_record = true, set_record_mode = true }
1171
+
1172
+ -- Rows for the first EDIT_MAX_ROWS states, and how many states did not fit
1173
+ local function record_rows(states)
1174
+ local rows, dropped = {}, 0
1175
+ for _, s in ipairs(states or {}) do
1176
+ if #rows < EDIT_MAX_ROWS then
1177
+ rows[#rows + 1] = { s.name or "", s.armed and 1 or 0, (s.input and s.input.label) or "", tostring(s.monitor or ""), tostring(s.mode or "") }
1178
+ else
1179
+ dropped = dropped + 1
1180
+ end
1181
+ end
1182
+ return rows, dropped
1183
+ end
1184
+
1185
+ local function record_global()
1186
+ local g = ctx.recording.global_state(api)
1187
+ return { mode = g.mode, path = g.recordPath, punch = g.punch and { g.punch.start, g.punch["end"] } or nil }
1188
+ end
1189
+
1190
+ local function record_tracks(params)
1191
+ local states = {}
1192
+ for _, u in ipairs(decode_param_list(params.tracks) or {}) do
1193
+ local tr = type(u) == "table" and ctx.resolve_track(u.track)
1194
+ if tr and #states < EDIT_MAX_ROWS then states[#states + 1] = ctx.recording.record_state(api, tr) end
1195
+ end
1196
+ return states
1197
+ end
1198
+
1199
+ local function record_view(cmd_type, params, data, before)
1200
+ local r = record_global()
1201
+ local dropped = 0
1202
+ if cmd_type == "get_record_setup" then
1203
+ r.rows, dropped = record_rows(type(data) == "table" and data.tracks or nil)
1204
+ dropped = dropped + (type(data) == "table" and tonumber(data.tracksMore) or 0)
1205
+ elseif cmd_type == "set_track_record" then
1206
+ r.rows, dropped = record_rows(type(data) == "table" and data.tracks or record_tracks(params))
1207
+ end
1208
+ if dropped > 0 then r.more = dropped end
1209
+ if before then
1210
+ r.was = { mode = before.mode, path = before.path, punch = before.punch, rows = before.rows }
1211
+ end
1212
+ return r
1213
+ end
1214
+
1215
+ -- Fixed lanes: [name, plays 0 off / 1 alone / 2 layered] per lane and [position, length, lane, 1 = MIDI] per item
1216
+ local LANE_COMMANDS = { get_track_lanes = true, set_track_lanes = true, comp_lanes = true }
1217
+ local PLAYS_CODE = { off = 0, exclusive = 1, layered = 2 }
1218
+
1219
+ local function lane_snapshot(tr)
1220
+ local st = ctx.recording.lanes_state(api, tr)
1221
+ local lanes, items = {}, {}
1222
+ -- Lanes past EDIT_MAX_ROWS are counted in `more`, their items left out (they have no row to sit on)
1223
+ for i, l in ipairs(st.lanes) do
1224
+ if i > EDIT_MAX_ROWS then break end
1225
+ lanes[i] = { l.name, PLAYS_CODE[l.plays] or 0 }
1226
+ for _, it in ipairs(l.items) do
1227
+ if #items < EDIT_MAX_NOTES / 2 then items[#items + 1] = { round2(it.position) or 0, round2(it.length) or 0, l.lane, it.midi and 1 or 0 } end
1228
+ end
1229
+ end
1230
+ local more = #st.lanes - #lanes
1231
+ return { t = st.name, fixed = st.fixedLanes and 1 or 0, lanes = lanes, items = items, more = more > 0 and more or nil }
1232
+ end
1233
+
1234
+ local function lane_track(params)
1235
+ local tr = ctx.resolve_track(params.track)
1236
+ if not tr or (api.GetMasterTrack and tr == api.GetMasterTrack(0)) then return nil end
1237
+ return tr
1238
+ end
1239
+
1240
+ local function lane_view(params, data, before)
1241
+ local tr = lane_track(params)
1242
+ if not tr then return nil end
1243
+ local r = lane_snapshot(tr)
1244
+ local len = 0
1245
+ for _, it in ipairs(r.items) do len = math.max(len, it[1] + it[2]) end
1246
+ if before then
1247
+ r.was = { lanes = before.lanes, items = before.items, fixed = before.fixed }
1248
+ for _, it in ipairs(before.items) do len = math.max(len, it[1] + it[2]) end
1249
+ end
1250
+ if type(data) == "table" and data.compLane then
1251
+ r.comp = data.compLane
1252
+ r.segs = {}
1253
+ for _, s in ipairs(data.segments or {}) do
1254
+ if #r.segs < EDIT_MAX_MARKS then r.segs[#r.segs + 1] = { round2(s.start) or 0, round2(s["end"]) or 0, s.lane } end
1255
+ end
1256
+ end
1257
+ r.len = round2(len) or 0
1258
+ return r
1259
+ end
1260
+
1261
+ -- The state a write is about to change, read before its handler runs (nil for every other command)
1262
+ function M.before(cmd_type, params)
1263
+ if cmd_type == "set_track_property" or cmd_type == "set_multiple_track_properties" then
1264
+ return read_track_values(track_targets(params, cmd_type))
1265
+ elseif cmd_type == "set_fx_parameter" or cmd_type == "set_multiple_fx_parameters" then
1266
+ return read_fx_values(fx_targets(params, cmd_type))
1267
+ elseif ENVELOPE_WRITES[cmd_type] or (AUTOMATION_ITEM_COMMANDS[cmd_type] and cmd_type ~= "list_automation_items") then
1268
+ local env = env_of(params)
1269
+ if not env then return nil end
1270
+ local pts = env_points(env)
1271
+ pts.items = env_items(env)
1272
+ return pts
1273
+ elseif MIDI_WRITES[cmd_type] then
1274
+ return midi_notes(params)
1275
+ elseif CC_COMMANDS[cmd_type] and cmd_type ~= "get_midi_cc" then
1276
+ return cc_events(params)
1277
+ elseif TIMELINE_WRITES[cmd_type] then
1278
+ return timeline_snapshot()
1279
+ elseif FX_CHAIN_COMMANDS[cmd_type] and cmd_type ~= "get_track_properties" then
1280
+ return fx_chains(params)
1281
+ elseif ITEM_COMMANDS[cmd_type] and cmd_type ~= "list_midi_items" then
1282
+ return item_lanes(params)
1283
+ elseif ARRANGE.OPS[cmd_type] or cmd_type == "process_items" then
1284
+ return ARRANGE.before(cmd_type, params)
1285
+ elseif cmd_type == "set_time_selection" or cmd_type == "set_cursor_position" then
1286
+ return time_state()
1287
+ elseif ROUTING_COMMANDS[cmd_type] and cmd_type ~= "get_track_routing" then
1288
+ local tr = routing_track(params)
1289
+ return tr and { track = tr, routes = routes_of(tr) }
1290
+ elseif STRUCTURE_COMMANDS[cmd_type] then
1291
+ local rows, ids = mixer_all()
1292
+ return { tracks = rows, ids = ids }
1293
+ elseif ctx.recording and (cmd_type == "set_track_record" or cmd_type == "set_record_mode") then
1294
+ local g = record_global()
1295
+ if cmd_type == "set_track_record" then g.rows = record_rows(record_tracks(params)) end
1296
+ return g
1297
+ elseif ctx.recording and (cmd_type == "set_track_lanes" or cmd_type == "comp_lanes") then
1298
+ local tr = lane_track(params)
1299
+ return tr and lane_snapshot(tr)
1300
+ elseif MIX.MONITOR[cmd_type] and cmd_type ~= "list_monitoring_fx" then
1301
+ return MIX.monitor_slots()
1302
+ end
1303
+ end
1304
+
1305
+ -- The entry's `r`: an edit view, a measurement, or nil
1306
+ function M.summary(cmd_type, params, data, before)
1307
+ if cmd_type == "set_track_property" or cmd_type == "set_multiple_track_properties" then
1308
+ return change_rows(before, read_track_values(track_targets(params, cmd_type)))
1309
+ elseif cmd_type == "set_fx_parameter" or cmd_type == "set_multiple_fx_parameters" then
1310
+ return change_rows(before, read_fx_values(fx_targets(params, cmd_type)))
1311
+ elseif ENVELOPE_WRITES[cmd_type] or AUTOMATION_ITEM_COMMANDS[cmd_type] or cmd_type == "get_envelope_points" then
1312
+ return envelope_view(cmd_type, params, data, before)
1313
+ elseif MIDI_WRITES[cmd_type] or cmd_type == "get_midi_notes" then
1314
+ return midi_view(params, before)
1315
+ elseif cmd_type == "import_midi_file" then
1316
+ -- The first created item, all notes new; the other items (one per new track) are counted
1317
+ local first = type(data) == "table" and type(data.items) == "table" and data.items[1]
1318
+ local view = first and first.itemIndex and midi_view({ trackIndex = first.trackIndex, itemIndex = first.itemIndex }, {})
1319
+ if view and #data.items > 1 then view.items = #data.items - 1 end
1320
+ return view
1321
+ elseif cmd_type == "analyze_midi" then
1322
+ return midi_stats_view(data)
1323
+ elseif CC_COMMANDS[cmd_type] then
1324
+ return cc_view(cmd_type, params, before)
1325
+ elseif TIMELINE_WRITES[cmd_type] or cmd_type == "list_markers" or cmd_type == "list_regions"
1326
+ or cmd_type == "get_tempo_map" or cmd_type == "list_media_items" then
1327
+ return timeline_view(cmd_type, data, before)
1328
+ elseif cmd_type == "list_tracks" or STRUCTURE_COMMANDS[cmd_type] then
1329
+ return mixer_view(before)
1330
+ elseif FX_CHAIN_COMMANDS[cmd_type] then
1331
+ return fx_chain_view(params, before)
1332
+ elseif ITEM_COMMANDS[cmd_type] then
1333
+ return item_view(params, before)
1334
+ elseif ARRANGE.OPS[cmd_type] or cmd_type == "process_items" then
1335
+ return ARRANGE.view(cmd_type, params, data, before)
1336
+ elseif TIME_COMMANDS[cmd_type] then
1337
+ return time_view(before)
1338
+ elseif ROUTING_COMMANDS[cmd_type] then
1339
+ -- A write reads the same track it read before (remove_send can leave nothing to resolve by index)
1340
+ local tr = before and before.track or routing_track(params)
1341
+ if not tr then return nil end
1342
+ return { t = name_of(tr), routes = routes_of(tr), was = before and before.routes or nil }
1343
+ elseif cmd_type == "get_fx_parameters" then
1344
+ return fx_param_rows(params)
1345
+ elseif cmd_type == "run_plan" then
1346
+ return plan_view(params, data)
1347
+ elseif cmd_type == "set_automation_mode" then
1348
+ return type(data) == "table" and MIX.automation(data) or nil
1349
+ elseif cmd_type == "get_fx_param_modulation" or cmd_type == "set_fx_param_modulation" then
1350
+ return MIX.modulation(cmd_type, data)
1351
+ elseif cmd_type == "get_fx_pins" or cmd_type == "set_fx_pins" then
1352
+ return MIX.pins(data)
1353
+ elseif cmd_type == "add_stretch_markers" or cmd_type == "edit_stretch_markers" or cmd_type == "quantize_audio" or cmd_type == "align_audio_to_guide" then
1354
+ return MIX.stretch(cmd_type, data)
1355
+ elseif cmd_type == "freeze_tracks" or cmd_type == "unfreeze_tracks" or cmd_type == "apply_fx_to_items" then
1356
+ return MIX.freeze(cmd_type, data)
1357
+ elseif MIX.MONITOR[cmd_type] then
1358
+ return { chains = { { t = "Monitoring FX", fx = MIX.monitor_slots(), was = before } } }
1359
+ elseif cmd_type == "render_project" or cmd_type == "render_versions" then
1360
+ return render_view(cmd_type, data)
1361
+ elseif RECORD_COMMANDS[cmd_type] then
1362
+ return ctx.recording and record_view(cmd_type, params, data, before)
1363
+ elseif LANE_COMMANDS[cmd_type] then
1364
+ return ctx.recording and lane_view(params, data, before)
1365
+ end
1366
+ local fields = RESULT_FIELDS[cmd_type]
1367
+ if not fields or type(data) ~= "table" then return nil end
1368
+ local r = {}
1369
+ for _, k in ipairs(fields) do r[k] = round2(data[k]) end
1370
+ if cmd_type == "read_track_spectrum" then r.bands = spectrum_bands(data) end
1371
+ if cmd_type == "render_track_to_wav" then
1372
+ local ok, wave = pcall(wav_waveform, data.wavPath)
1373
+ if ok and wave then for k, v in pairs(wave) do r[k] = v end end
1374
+ end
1375
+ return r
1376
+ end
1377
+
1378
+ return M