@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
@@ -4,7 +4,7 @@ desc:MCP LUFS Meter
4
4
  // Writes results to shared memory (gmem) so the Lua bridge can read them.
5
5
  //
6
6
  // gmem layout (per track slot, 8 slots each):
7
- // base = track_slot * 8
7
+ // base = track_slot * 8 (the bridge sets track_slot = trackIndex + 1, master = 0)
8
8
  // gmem[base+0] = integrated LUFS (running average since last reset)
9
9
  // gmem[base+1] = short-term LUFS (3-second sliding window)
10
10
  // gmem[base+2] = momentary LUFS (400ms sliding window)
@@ -15,7 +15,7 @@ desc:MCP LUFS Meter
15
15
  options:gmem=MCPLufsMeter
16
16
 
17
17
  slider1:reset_btn=0<0,1,1{Idle,Reset}>Reset Measurement
18
- slider2:track_slot=0<0,127,1>Track Slot
18
+ slider2:track_slot=0<0,4095,1>Track Slot
19
19
 
20
20
  @init
21
21
  // K-weighting filter state (two biquad stages per channel)
@@ -33,8 +33,9 @@ slider2:track_slot=0<0,127,1>Track Slot
33
33
  // Ring buffer for per-sample squared weighted values (up to 3s)
34
34
  // momentary: 400ms window, short-term: 3s window
35
35
  buf_size = 0;
36
- buf_l = 65536; // start address for left channel ring buffer
37
- buf_r = 131072; // start address for right channel ring buffer
36
+ // Each buffer holds up to 262144 samples (+2 for the true-peak history)
37
+ buf_l = 0; // start address for left channel ring buffer
38
+ buf_r = 270000; // start address for right channel ring buffer
38
39
  buf_pos = 0;
39
40
 
40
41
  // Running sums for windows
@@ -79,7 +80,7 @@ slider2:track_slot=0<0,127,1>Track Slot
79
80
  );
80
81
 
81
82
  function reset_measurement() (
82
- base = floor(slider2 + 0.5) * 8;
83
+ base = floor(track_slot + 0.5) * 8;
83
84
  buf_pos = 0;
84
85
  momentary_sum_l = 0;
85
86
  momentary_sum_r = 0;
@@ -110,10 +111,11 @@ slider2:track_slot=0<0,127,1>Track Slot
110
111
  needs_init = 1;
111
112
 
112
113
  // Handle reset button
113
- slider1 == 1 ? (
114
+ // Named sliders live in their variables; slider1/slider2 stay 0
115
+ reset_btn == 1 ? (
114
116
  reset_measurement();
115
- slider1 = 0;
116
- sliderchange(slider1);
117
+ reset_btn = 0;
118
+ sliderchange(1); // mask: slider1
117
119
  );
118
120
 
119
121
  @sample
@@ -211,17 +213,18 @@ slider2:track_slot=0<0,127,1>Track Slot
211
213
 
212
214
  // --- Update gmem every 4096 samples (approx 10x/second at 44.1kHz) ---
213
215
  (sample_count % 4096) == 0 ? (
214
- base = floor(slider2 + 0.5) * 8;
216
+ base = floor(track_slot + 0.5) * 8;
215
217
 
216
218
  mom_samples = floor(srate * 0.4);
217
219
  mom_samples < 1 ? mom_samples = 1;
218
220
 
219
221
  // Mean square over each window
220
- shortterm_mean = (shortterm_sum_l + shortterm_sum_r) / (buf_size * 2);
221
- momentary_mean = (momentary_sum_l + momentary_sum_r) / (mom_samples * 2);
222
+ // BS.1770 sums the channels' mean squares (G = 1 for L/R); it does not average them
223
+ shortterm_mean = (shortterm_sum_l + shortterm_sum_r) / buf_size;
224
+ momentary_mean = (momentary_sum_l + momentary_sum_r) / mom_samples;
222
225
 
223
- // Integrated mean square (two channels)
224
- integrated_mean = integrated_count > 0 ? (integrated_sum / (integrated_count * 2)) : 0;
226
+ // Integrated: sum of the two channels' mean squares
227
+ integrated_mean = integrated_count > 0 ? (integrated_sum / integrated_count) : 0;
225
228
 
226
229
  // LUFS = -0.691 + 10 * log10(mean_square)
227
230
  shortterm_mean > 0 ? (
@@ -0,0 +1,555 @@
1
+ -- mcp_midi_lib.lua — MIDI composition edits: quantize, transform (transpose, velocity,
2
+ -- humanize, lengths), import of parsed MIDI file data, the pitch-class profile detect_key
3
+ -- reads, and the bar/beat grid with per-segment profiles detect_chords reads.
4
+ -- Pure logic over an injected ReaScript API, unit-tested via wasmoon against a fake.
5
+ -- Loaded by mcp_bridge.lua.
6
+ -- Every function checks all of its input before the first write, so a refused request
7
+ -- changes nothing; each write is one undo point. Times are quarter notes (beats) on the
8
+ -- project grid: quantize snaps to the project's beats, not the item's start.
9
+
10
+ local M = {}
11
+
12
+ M.MAX_IMPORT_NOTES = 20000
13
+ M.MAX_IMPORT_TRACKS = 32
14
+ M.MAX_PROFILE_ITEMS = 256
15
+
16
+ local EPS = 1e-9
17
+
18
+ local function num(v) return type(v) == "number" and v == v and v ~= math.huge and v ~= -math.huge end
19
+ local function int(v) return num(v) and v == math.floor(v) end
20
+ local function r4(x) return math.floor(x * 10000 + 0.5) / 10000 end
21
+
22
+ local function with_undo(api, label, fn)
23
+ api.Undo_BeginBlock()
24
+ local ok, res, err = pcall(fn)
25
+ if ok then
26
+ api.Undo_EndBlock(label, -1)
27
+ api.UpdateArrange()
28
+ return res, err
29
+ end
30
+ -- A failure half way (an import that made some tracks) leaves no partial edit. The failed
31
+ -- block gets its own label, so a failure that wrote nothing (no undo point) never undoes
32
+ -- the user's last edit, even one by this same tool
33
+ local revert = label .. " (failed — reverting)"
34
+ api.Undo_EndBlock(revert, -1)
35
+ local undone = api.Undo_CanUndo2 and api.Undo_CanUndo2(0) == revert
36
+ if undone then api.Undo_DoUndo2(0) end
37
+ api.UpdateArrange()
38
+ return nil, "Handler error: " .. tostring(res) .. (undone and " — the edit was undone" or "")
39
+ end
40
+
41
+ -- The MIDI take of a track/item pair, or why not
42
+ local function midi_take(api, p)
43
+ if not int(p.trackIndex) or p.trackIndex < 0 then return nil, "trackIndex must be 0 or more" end
44
+ if not int(p.itemIndex) or p.itemIndex < 0 then return nil, "itemIndex must be 0 or more" end
45
+ local tr = api.GetTrack(0, p.trackIndex)
46
+ if not tr then return nil, "track " .. p.trackIndex .. " not found" end
47
+ local n = api.CountTrackMediaItems(tr)
48
+ if p.itemIndex >= n then return nil, "item " .. p.itemIndex .. " not found (track has " .. n .. " items)" end
49
+ local item = api.GetTrackMediaItem(tr, p.itemIndex)
50
+ local take = item and api.GetActiveTake(item)
51
+ if not take then return nil, "the item has no active take" end
52
+ if not api.TakeIsMIDI(take) then return nil, "item " .. p.itemIndex .. " is not a MIDI item" end
53
+ return { track = tr, item = item, take = take, startQN = api.TimeMap2_timeToQN(0, api.GetMediaItemInfo_Value(item, "D_POSITION")) }
54
+ end
55
+
56
+ -- Every note of a take as { i, sel, muted, s, e, qs, qe, chan, pitch, vel } (q* = project quarter notes)
57
+ local function read_notes(api, t)
58
+ local _, count = api.MIDI_CountEvts(t.take)
59
+ local notes = {}
60
+ for i = 0, count - 1 do
61
+ local _, sel, muted, s, e, chan, pitch, vel = api.MIDI_GetNote(t.take, i)
62
+ notes[#notes + 1] = { i = i, sel = sel, muted = muted, s = s, e = e, chan = chan, pitch = pitch, vel = vel,
63
+ qs = api.MIDI_GetProjQNFromPPQPos(t.take, s), qe = api.MIDI_GetProjQNFromPPQPos(t.take, e) }
64
+ end
65
+ return notes
66
+ end
67
+
68
+ -- The notes a call acts on: given indexes, else a pitch range and/or channel, else all
69
+ local function pick(notes, p)
70
+ if p.notes ~= nil then
71
+ if type(p.notes) ~= "table" or #p.notes == 0 then return nil, "notes must be a list of note indexes" end
72
+ local out, seen = {}, {}
73
+ for _, i in ipairs(p.notes) do
74
+ if not int(i) or i < 0 or i >= #notes then return nil, "note " .. tostring(i) .. " not found (the item has " .. #notes .. " notes)" end
75
+ if not seen[i] then seen[i] = true; out[#out + 1] = notes[i + 1] end
76
+ end
77
+ return out
78
+ end
79
+ local lo, hi = p.pitchMin or 0, p.pitchMax or 127
80
+ if not int(lo) or not int(hi) or lo < 0 or hi > 127 or lo > hi then return nil, "pitchMin/pitchMax must be 0-127, min ≤ max" end
81
+ if p.channel ~= nil and (not int(p.channel) or p.channel < 0 or p.channel > 15) then return nil, "channel must be 0-15" end
82
+ local out = {}
83
+ for _, n in ipairs(notes) do
84
+ if n.pitch >= lo and n.pitch <= hi and (p.channel == nil or n.chan == p.channel) then out[#out + 1] = n end
85
+ end
86
+ if #out == 0 then return nil, "no notes match" end
87
+ return out
88
+ end
89
+
90
+ local function write_notes(api, t, changed)
91
+ local ok, err = pcall(function()
92
+ for _, n in ipairs(changed) do
93
+ api.MIDI_SetNote(t.take, n.i, nil, nil, api.MIDI_GetPPQPosFromProjQN(t.take, n.nqs),
94
+ api.MIDI_GetPPQPosFromProjQN(t.take, n.nqe), nil, n.npitch, n.nvel, true)
95
+ end
96
+ end)
97
+ api.MIDI_Sort(t.take)
98
+ -- MIDI API edits leave the item unmarked; without this REAPER folds the edit into the previous
99
+ -- MIDI undo point and undo restores a mixed state (seen in REAPER 7.40). Marked even when a
100
+ -- write failed half way, so the failed block has an undo point for with_undo to revert
101
+ api.MarkTrackItemsDirty(t.track, t.item)
102
+ if not ok then error(err, 0) end
103
+ end
104
+
105
+ -- -----------------------------------------------------------------------------
106
+ -- Quantize
107
+ -- -----------------------------------------------------------------------------
108
+
109
+ -- The grid line nearest qn, with every second line pushed later by `swing` of a grid step
110
+ local function snap(qn, grid, swing)
111
+ local k = math.floor(qn / grid + 0.5)
112
+ local best, best_d
113
+ for j = k - 1, k + 1 do
114
+ local line = j * grid + ((j % 2 == 1) and swing * grid or 0)
115
+ local d = math.abs(qn - line)
116
+ if not best_d or d < best_d then best, best_d = line, d end
117
+ end
118
+ return best
119
+ end
120
+
121
+ -- The grid line after a line (with swing, odd lines sit swing steps late)
122
+ local function line_after(line, grid, swing)
123
+ local j = math.floor((line + EPS) / grid) + 1
124
+ return j * grid + ((j % 2 == 1) and swing * grid or 0)
125
+ end
126
+
127
+ local function check_quantize(api, p)
128
+ local t, err = midi_take(api, p)
129
+ if not t then return nil, err end
130
+ if not num(p.grid) or p.grid <= 0 or p.grid > 16 then return nil, "grid must be more than 0 and at most 16 beats (0.25 = 16ths)" end
131
+ local strength = p.strength == nil and 1 or p.strength
132
+ if not num(strength) or strength < 0 or strength > 1 then return nil, "strength must be 0-1" end
133
+ local swing = p.swing or 0
134
+ if not num(swing) or swing < 0 or swing > 0.9 then return nil, "swing must be 0-0.9" end
135
+ local window = p.window == nil and 1 or p.window
136
+ if not num(window) or window <= 0 or window > 1 then return nil, "window must be more than 0 and at most 1" end
137
+ local lengths = p.lengths or "keep"
138
+ if lengths ~= "keep" and lengths ~= "quantize" then return nil, "lengths must be keep or quantize" end
139
+ local picked, perr = pick(read_notes(api, t), p)
140
+ if not picked then return nil, perr end
141
+ return { t = t, notes = picked, grid = p.grid, strength = strength, swing = swing, window = window, lengths = lengths }
142
+ end
143
+
144
+ local function apply_quantize(api, c)
145
+ local changed, shifts, max_shift = {}, 0, 0
146
+ -- With swing the lines are up to (1 + swing) steps apart, so window 1 still reaches every note
147
+ local reach = c.window * (1 + c.swing) * c.grid / 2 + EPS
148
+ for _, n in ipairs(c.notes) do
149
+ local line = snap(n.qs, c.grid, c.swing)
150
+ -- Never before the item start, where REAPER would not play the note
151
+ while line < c.t.startQN - EPS do line = line_after(line, c.grid, c.swing) end
152
+ local off = line - n.qs
153
+ -- Notes further from a line than the window are left where they are
154
+ if math.abs(off) <= reach then
155
+ -- Blend, so a full-strength note lands exactly on the line
156
+ n.nqs = line * c.strength + n.qs * (1 - c.strength)
157
+ if c.lengths == "quantize" then
158
+ local e = snap(n.qe, c.grid, c.swing)
159
+ if e <= line + EPS then e = line_after(line, c.grid, c.swing) end
160
+ n.nqe = e * c.strength + n.qe * (1 - c.strength)
161
+ else
162
+ n.nqe = n.qe + (n.nqs - n.qs)
163
+ end
164
+ -- A note already on the grid still counts when its end snapped
165
+ if math.abs(n.nqs - n.qs) > EPS or math.abs(n.nqe - n.qe) > EPS then
166
+ changed[#changed + 1] = n
167
+ shifts = shifts + math.abs(off)
168
+ if math.abs(off) > max_shift then max_shift = math.abs(off) end
169
+ end
170
+ end
171
+ end
172
+ write_notes(api, c.t, changed)
173
+ return { notes = #c.notes, moved = #changed, grid = c.grid, strength = c.strength, swing = c.swing,
174
+ avgShiftBeats = #changed > 0 and r4(shifts / #changed) or 0, maxShiftBeats = r4(max_shift) }
175
+ end
176
+
177
+ function M.quantize(api, p)
178
+ local c, err = check_quantize(api, p)
179
+ if not c then return nil, err .. " — nothing changed" end
180
+ return with_undo(api, "MCP: Quantize MIDI", function() return apply_quantize(api, c) end)
181
+ end
182
+
183
+ -- -----------------------------------------------------------------------------
184
+ -- Transform: transpose, velocity, humanize, lengths
185
+ -- -----------------------------------------------------------------------------
186
+
187
+ -- Reproducible pseudo-random numbers in -1..1 (a seed gives the same humanize every time)
188
+ local function rng(seed)
189
+ local state = (math.floor(seed) % 2147483646) + 1
190
+ return function()
191
+ state = (state * 16807) % 2147483647
192
+ return (state / 2147483647) * 2 - 1
193
+ end
194
+ end
195
+
196
+ local function check_transform(api, p)
197
+ local t, err = midi_take(api, p)
198
+ if not t then return nil, err end
199
+ local c = { t = t }
200
+ if p.transpose ~= nil and (not int(p.transpose) or p.transpose < -48 or p.transpose > 48) then return nil, "transpose must be a whole number of semitones, -48 to 48" end
201
+ local v = p.velocity or {}
202
+ if type(v) ~= "table" then return nil, "velocity must be an object" end
203
+ if v.set ~= nil and (not int(v.set) or v.set < 1 or v.set > 127) then return nil, "velocity.set must be 1-127" end
204
+ if v.scale ~= nil and (not num(v.scale) or v.scale < 0 or v.scale > 4) then return nil, "velocity.scale must be 0-4" end
205
+ if v.add ~= nil and (not int(v.add) or v.add < -126 or v.add > 126) then return nil, "velocity.add must be -126 to 126" end
206
+ local vmin, vmax = v.min or 1, v.max or 127
207
+ if not int(vmin) or not int(vmax) or vmin < 1 or vmax > 127 or vmin > vmax then return nil, "velocity.min/max must be 1-127, min ≤ max" end
208
+ local h = p.humanize or {}
209
+ if type(h) ~= "table" then return nil, "humanize must be an object" end
210
+ if h.timingBeats ~= nil and (not num(h.timingBeats) or h.timingBeats < 0 or h.timingBeats > 0.25) then return nil, "humanize.timingBeats must be 0-0.25" end
211
+ if h.velocity ~= nil and (not int(h.velocity) or h.velocity < 0 or h.velocity > 64) then return nil, "humanize.velocity must be 0-64" end
212
+ if h.seed ~= nil and not int(h.seed) then return nil, "humanize.seed must be a whole number" end
213
+ local l = p.length or {}
214
+ if type(l) ~= "table" then return nil, "length must be an object" end
215
+ if l.set ~= nil and (not num(l.set) or l.set <= 0 or l.set > 64) then return nil, "length.set must be more than 0 and at most 64 beats" end
216
+ if l.scale ~= nil and (not num(l.scale) or l.scale <= 0 or l.scale > 8) then return nil, "length.scale must be more than 0 and at most 8" end
217
+ if l.legato ~= nil and type(l.legato) ~= "boolean" then return nil, "length.legato must be true or false" end
218
+ if p.transpose == nil and next(v) == nil and next(h) == nil and next(l) == nil then
219
+ return nil, "give at least one of transpose, velocity, humanize or length"
220
+ end
221
+ local all = read_notes(api, t)
222
+ local picked, perr = pick(all, p)
223
+ if not picked then return nil, perr end
224
+ if p.transpose then
225
+ local out = 0
226
+ for _, n in ipairs(picked) do
227
+ local np = n.pitch + p.transpose
228
+ if np < 0 or np > 127 then out = out + 1 end
229
+ end
230
+ if out > 0 then return nil, out .. " note(s) would leave the MIDI range 0-127 when transposed by " .. p.transpose end
231
+ end
232
+ c.all, c.notes, c.transpose, c.v, c.vmin, c.vmax, c.h, c.l = all, picked, p.transpose or 0, v, vmin, vmax, h, l
233
+ return c
234
+ end
235
+
236
+ local function apply_transform(api, c)
237
+ local rand = rng(c.h.seed or 1)
238
+ local v, h, l = c.v, c.h, c.l
239
+ local clamped, vel_before, vel_after = 0, 0, 0
240
+ -- Starts first (humanize moves them), so legato ends meet the notes where they end up
241
+ for _, n in ipairs(c.notes) do
242
+ n.nqs = n.qs
243
+ if h.timingBeats and h.timingBeats > 0 then n.nqs = math.max(c.t.startQN, n.qs + rand() * h.timingBeats) end
244
+ end
245
+ -- Legato: each note runs to the next picked note's start on the same channel
246
+ local next_start = {}
247
+ if l.legato then
248
+ local order = {}
249
+ for _, n in ipairs(c.notes) do order[#order + 1] = n end
250
+ table.sort(order, function(a, b) return a.nqs < b.nqs end)
251
+ for k, n in ipairs(order) do
252
+ for j = k + 1, #order do
253
+ if order[j].chan == n.chan and order[j].nqs > n.nqs + EPS then next_start[n] = order[j].nqs; break end
254
+ end
255
+ end
256
+ end
257
+ for _, n in ipairs(c.notes) do
258
+ n.npitch = n.pitch + c.transpose
259
+ local vel = n.vel
260
+ if v.set then vel = v.set end
261
+ if v.scale then vel = vel * v.scale end
262
+ if v.add then vel = vel + v.add end
263
+ if h.velocity and h.velocity > 0 then vel = vel + rand() * h.velocity end
264
+ vel = math.floor(vel + 0.5)
265
+ local cl = math.max(c.vmin, math.min(c.vmax, vel))
266
+ if cl ~= vel then clamped = clamped + 1 end
267
+ n.nvel = cl
268
+ vel_before, vel_after = vel_before + n.vel, vel_after + cl
269
+ local len = n.qe - n.qs
270
+ if l.set then len = l.set end
271
+ if l.scale then len = len * l.scale end
272
+ if l.legato and next_start[n] then len = next_start[n] - n.nqs end
273
+ n.nqe = n.nqs + math.max(len, 1 / 960)
274
+ end
275
+ write_notes(api, c.t, c.notes)
276
+ local n = #c.notes
277
+ return { notes = n, transpose = c.transpose, velocityClamped = clamped,
278
+ avgVelocityBefore = r4(vel_before / n), avgVelocityAfter = r4(vel_after / n),
279
+ seed = (h.timingBeats or h.velocity) and (h.seed or 1) or nil }
280
+ end
281
+
282
+ function M.transform(api, p)
283
+ local c, err = check_transform(api, p)
284
+ if not c then return nil, err .. " — nothing changed" end
285
+ return with_undo(api, "MCP: Transform MIDI", function() return apply_transform(api, c) end)
286
+ end
287
+
288
+ -- -----------------------------------------------------------------------------
289
+ -- Import parsed MIDI file data
290
+ -- -----------------------------------------------------------------------------
291
+
292
+ -- p.tracks: [{ name, notes = {{startQN, lenQN, chan, pitch, vel}, ...}, cc = {{qn, chan, cc, value}, ...} }]
293
+ local function check_import(api, p)
294
+ if not num(p.position) or p.position < 0 then return nil, "position must be 0 or more seconds" end
295
+ if type(p.tracks) ~= "table" or #p.tracks == 0 then return nil, "the file holds no notes or controllers" end
296
+ if p.newTracks and #p.tracks > M.MAX_IMPORT_TRACKS then return nil, "the file has " .. #p.tracks .. " tracks with notes; at most " .. M.MAX_IMPORT_TRACKS end
297
+ if not num(p.lengthQN) or p.lengthQN <= 0 then return nil, "lengthQN must be more than 0" end
298
+ local n_tracks = api.CountTracks(0)
299
+ if not int(p.trackIndex) or p.trackIndex < 0 then return nil, "trackIndex must be 0 or more" end
300
+ if p.newTracks then
301
+ if p.trackIndex > n_tracks then return nil, "trackIndex " .. p.trackIndex .. " is past the last track (" .. n_tracks .. " tracks)" end
302
+ elseif p.trackIndex >= n_tracks then
303
+ return nil, "track " .. p.trackIndex .. " not found"
304
+ end
305
+ local total = 0
306
+ for k, t in ipairs(p.tracks) do
307
+ if type(t.notes) ~= "table" or type(t.cc or {}) ~= "table" then return nil, "track " .. k .. " is malformed" end
308
+ total = total + #t.notes
309
+ for _, n in ipairs(t.notes) do
310
+ if not (num(n[1]) and n[1] >= 0 and num(n[2]) and n[2] > 0 and int(n[3]) and n[3] >= 0 and n[3] <= 15
311
+ and int(n[4]) and n[4] >= 0 and n[4] <= 127 and int(n[5]) and n[5] >= 1 and n[5] <= 127) then
312
+ return nil, "track " .. k .. " has a malformed note"
313
+ end
314
+ end
315
+ for _, e in ipairs(t.cc or {}) do
316
+ if not (num(e[1]) and e[1] >= 0 and int(e[2]) and e[2] >= 0 and e[2] <= 15 and int(e[3]) and e[3] >= 0
317
+ and e[3] <= 127 and int(e[4]) and e[4] >= 0 and e[4] <= 127) then
318
+ return nil, "track " .. k .. " has a malformed controller event"
319
+ end
320
+ end
321
+ end
322
+ if total > M.MAX_IMPORT_NOTES then return nil, "the file has " .. total .. " notes; at most " .. M.MAX_IMPORT_NOTES .. " per import" end
323
+ return p
324
+ end
325
+
326
+ local function apply_import(api, p)
327
+ local startQN = api.TimeMap2_timeToQN(0, p.position)
328
+ local endQN = startQN + p.lengthQN
329
+ local groups = p.tracks
330
+ if not p.newTracks and #groups > 1 then
331
+ -- One item: every file track's notes merged
332
+ local merged = { name = p.name or groups[1].name, notes = {}, cc = {} }
333
+ for _, t in ipairs(groups) do
334
+ for _, n in ipairs(t.notes) do merged.notes[#merged.notes + 1] = n end
335
+ for _, e in ipairs(t.cc or {}) do merged.cc[#merged.cc + 1] = e end
336
+ end
337
+ groups = { merged }
338
+ end
339
+ local items = {}
340
+ for k, g in ipairs(groups) do
341
+ local idx = p.trackIndex
342
+ if p.newTracks then
343
+ idx = p.trackIndex + k - 1
344
+ api.InsertTrackAtIndex(idx, true)
345
+ if g.name and g.name ~= "" then api.GetSetMediaTrackInfo_String(api.GetTrack(0, idx), "P_NAME", g.name, true) end
346
+ end
347
+ local tr = api.GetTrack(0, idx)
348
+ local item = api.CreateNewMIDIItemInProj(tr, startQN, endQN, true)
349
+ local take = api.GetActiveTake(item)
350
+ if g.name and g.name ~= "" then api.GetSetMediaItemTakeInfo_String(take, "P_NAME", g.name, true) end
351
+ for _, n in ipairs(g.notes) do
352
+ api.MIDI_InsertNote(take, false, false, api.MIDI_GetPPQPosFromProjQN(take, startQN + n[1]),
353
+ api.MIDI_GetPPQPosFromProjQN(take, startQN + n[1] + n[2]), n[3], n[4], n[5], true)
354
+ end
355
+ for _, e in ipairs(g.cc or {}) do
356
+ api.MIDI_InsertCC(take, false, false, api.MIDI_GetPPQPosFromProjQN(take, startQN + e[1]), 0xB0, e[2], e[3], e[4])
357
+ end
358
+ api.MIDI_Sort(take)
359
+ api.MarkTrackItemsDirty(tr, item)
360
+ local itemIndex
361
+ for i = 0, api.CountTrackMediaItems(tr) - 1 do
362
+ if api.GetTrackMediaItem(tr, i) == item then itemIndex = i end
363
+ end
364
+ items[#items + 1] = { trackIndex = idx, itemIndex = itemIndex, name = g.name or "", notes = #g.notes, cc = #(g.cc or {}) }
365
+ end
366
+ return { position = p.position, lengthBeats = r4(p.lengthQN), items = items }
367
+ end
368
+
369
+ function M.import(api, p)
370
+ local c, err = check_import(api, p)
371
+ if not c then return nil, err .. " — nothing changed" end
372
+ return with_undo(api, "MCP: Import MIDI file", function() return apply_import(api, c) end)
373
+ end
374
+
375
+ -- -----------------------------------------------------------------------------
376
+ -- Pitch-class profile (for detect_key)
377
+ -- -----------------------------------------------------------------------------
378
+
379
+ -- Every heard note of a track's MIDI items overlapping [t0, t1) as { qs, qe, pitch, w } in project
380
+ -- quarter notes, clipped to the range and the item edges; w = velocity / 127. Unmuted notes of
381
+ -- unmuted items only, drums (GM channel 10) skipped. Also the item counts.
382
+ local function heard_notes(api, tr, t0, t1)
383
+ local q0 = api.TimeMap2_timeToQN(0, t0)
384
+ local q1 = t1 and api.TimeMap2_timeToQN(0, t1) or math.huge
385
+ local out = {}
386
+ local counts = { notes = 0, midiItems = 0, audioItems = 0, capped = 0 }
387
+ -- The cap counts only items that overlap the range, so a late range on a long track is still read
388
+ local read = 0
389
+ for i = 0, api.CountTrackMediaItems(tr) - 1 do
390
+ local item = api.GetTrackMediaItem(tr, i)
391
+ local take = api.GetActiveTake(item)
392
+ local ipos = api.GetMediaItemInfo_Value(item, "D_POSITION")
393
+ local overlaps = ipos < (t1 or math.huge) and ipos + api.GetMediaItemInfo_Value(item, "D_LENGTH") > t0
394
+ if overlaps and read >= M.MAX_PROFILE_ITEMS then counts.capped = counts.capped + 1; overlaps = false end
395
+ if overlaps then read = read + 1 end
396
+ if overlaps and take and api.GetMediaItemInfo_Value(item, "B_MUTE") == 0 then
397
+ if api.TakeIsMIDI(take) then
398
+ counts.midiItems = counts.midiItems + 1
399
+ -- Only what the item shows: notes past a trimmed edge are not heard (a loop's repeats count once)
400
+ local i0 = math.max(q0, api.TimeMap2_timeToQN(0, ipos))
401
+ local i1 = math.min(q1, api.TimeMap2_timeToQN(0, ipos + api.GetMediaItemInfo_Value(item, "D_LENGTH")))
402
+ local _, count = api.MIDI_CountEvts(take)
403
+ for n = 0, count - 1 do
404
+ local _, _, muted, s, e, chan, pitch, vel = api.MIDI_GetNote(take, n)
405
+ local qs = math.max(api.MIDI_GetProjQNFromPPQPos(take, s), i0)
406
+ local qe = math.min(api.MIDI_GetProjQNFromPPQPos(take, e), i1)
407
+ -- Drums (GM channel 10) carry no key
408
+ if not muted and chan ~= 9 and qe > qs then
409
+ out[#out + 1] = { qs = qs, qe = qe, pitch = pitch, w = vel / 127 }
410
+ counts.notes = counts.notes + 1
411
+ end
412
+ end
413
+ else
414
+ counts.audioItems = counts.audioItems + 1
415
+ end
416
+ end
417
+ end
418
+ return out, counts
419
+ end
420
+
421
+ -- Note time per pitch class (C = 1 … B = 12) over a track's MIDI items in [start, end),
422
+ -- weighted by duration and velocity. Unmuted notes of unmuted items only.
423
+ function M.pitch_profile(api, p)
424
+ if not int(p.trackIndex) or p.trackIndex < 0 then return nil, "trackIndex must be 0 or more" end
425
+ local tr = api.GetTrack(0, p.trackIndex)
426
+ if not tr then return nil, "track " .. p.trackIndex .. " not found" end
427
+ local t0, t1 = p.startTime or 0, p.endTime
428
+ if not num(t0) or t0 < 0 then return nil, "startTime must be 0 or more" end
429
+ if t1 ~= nil and (not num(t1) or t1 <= t0) then return nil, "endTime must be after startTime" end
430
+ local notes, c = heard_notes(api, tr, t0, t1)
431
+ local chroma = {}
432
+ for k = 1, 12 do chroma[k] = 0 end
433
+ for _, n in ipairs(notes) do chroma[n.pitch % 12 + 1] = chroma[n.pitch % 12 + 1] + (n.qe - n.qs) * n.w end
434
+ for k = 1, 12 do chroma[k] = r4(chroma[k]) end
435
+ local out = { chroma = chroma, notes = c.notes, midiItems = c.midiItems, audioItems = c.audioItems }
436
+ if c.capped > 0 then out.itemsNotRead = c.capped end
437
+ return out
438
+ end
439
+
440
+ M.MAX_CHORD_SEGMENTS = 4000
441
+
442
+ -- The range detect_chords / detect_key read: an item's bounds (itemIndex), else startTime to endTime,
443
+ -- where a missing end is where the track's last item ends (the project's length on the master)
444
+ local function chord_range(api, p)
445
+ if not int(p.trackIndex) or p.trackIndex < -1 then return nil, "trackIndex must be -1 (master) or more" end
446
+ local tr = p.trackIndex == -1 and api.GetMasterTrack(0) or api.GetTrack(0, p.trackIndex)
447
+ if not tr then return nil, "track " .. p.trackIndex .. " not found" end
448
+ if p.itemIndex ~= nil then
449
+ if p.trackIndex < 0 then return nil, "the master track holds no items: give a track index with itemIndex" end
450
+ if not int(p.itemIndex) or p.itemIndex < 0 then return nil, "itemIndex must be 0 or more" end
451
+ local n = api.CountTrackMediaItems(tr)
452
+ if p.itemIndex >= n then return nil, "item " .. p.itemIndex .. " not found (track has " .. n .. " items)" end
453
+ local item = api.GetTrackMediaItem(tr, p.itemIndex)
454
+ local pos = api.GetMediaItemInfo_Value(item, "D_POSITION")
455
+ return tr, pos, pos + api.GetMediaItemInfo_Value(item, "D_LENGTH")
456
+ end
457
+ local t0, t1 = p.startTime or 0, p.endTime
458
+ if not num(t0) or t0 < 0 then return nil, "startTime must be 0 or more" end
459
+ if t1 == nil then
460
+ if p.trackIndex == -1 then
461
+ t1 = api.GetProjectLength(0)
462
+ else
463
+ t1 = 0
464
+ for i = 0, api.CountTrackMediaItems(tr) - 1 do
465
+ local item = api.GetTrackMediaItem(tr, i)
466
+ t1 = math.max(t1, api.GetMediaItemInfo_Value(item, "D_POSITION") + api.GetMediaItemInfo_Value(item, "D_LENGTH"))
467
+ end
468
+ end
469
+ if t1 <= t0 then return nil, "nothing to read: the track has no items after " .. t0 .. " s" end
470
+ elseif not num(t1) or t1 <= t0 then
471
+ return nil, "endTime must be after startTime"
472
+ end
473
+ return tr, t0, t1
474
+ end
475
+
476
+ -- The range an item covers, for detect_key's itemIndex
477
+ function M.item_range(api, p)
478
+ local tr, t0, t1 = chord_range(api, p)
479
+ if not tr then return nil, t0 end
480
+ return { startTime = t0, endTime = t1 }
481
+ end
482
+
483
+ -- Segments of [t0, t1) on the project grid, one per bar or beat (tempo and time signature
484
+ -- changes followed), each { start, end, bar, beat } with 1-based bar and beat; the first and
485
+ -- last are clipped to the range
486
+ local function grid_segments(api, t0, t1, per)
487
+ local segs = {}
488
+ local _, m = api.TimeMap2_timeToBeats(0, t0)
489
+ m = math.floor(m)
490
+ while true do
491
+ local _, qs, qe, numer, denom = api.TimeMap_GetMeasureInfo(0, m)
492
+ local step = per == "beat" and 4 / denom or (qe - qs)
493
+ local beats = per == "beat" and numer or 1
494
+ for k = 0, beats - 1 do
495
+ local a = api.TimeMap2_QNToTime(0, qs + k * step)
496
+ local b = api.TimeMap2_QNToTime(0, k == beats - 1 and qe or qs + (k + 1) * step)
497
+ if a >= t1 - EPS then return segs end
498
+ a, b = math.max(a, t0), math.min(b, t1)
499
+ if b - a > 1e-6 then
500
+ if #segs >= M.MAX_CHORD_SEGMENTS then return nil end
501
+ segs[#segs + 1] = { start = r4(a), ["end"] = r4(b), bar = m + 1, beat = k + 1, q0 = api.TimeMap2_timeToQN(0, a), q1 = api.TimeMap2_timeToQN(0, b) }
502
+ end
503
+ end
504
+ m = m + 1
505
+ end
506
+ end
507
+
508
+ -- detect_chords: the range (item bounds or start/end), one segment per bar or beat, and with
509
+ -- midi = true each segment's pitch-class profile (note time x velocity) and lowest held note
510
+ function M.chord_grid(api, p)
511
+ local tr, t0, t1 = chord_range(api, p)
512
+ if not tr then return nil, t0 end
513
+ local per = p.per or "bar"
514
+ if per ~= "bar" and per ~= "beat" then return nil, 'per must be "bar" or "beat"' end
515
+ if p.midi and p.trackIndex < 0 then return nil, "the master track holds no MIDI notes" end
516
+ local segs = grid_segments(api, t0, t1, per)
517
+ if not segs then return nil, "more than " .. M.MAX_CHORD_SEGMENTS .. " " .. per .. "s in the range: use a shorter range" .. (per == "beat" and ' or per "bar"' or "") end
518
+ local out = { startTime = r4(t0), endTime = r4(t1), segments = {} }
519
+ local profiles, bass = {}, {}
520
+ for i, s in ipairs(segs) do
521
+ out.segments[i] = { start = s.start, ["end"] = s["end"], bar = s.bar, beat = s.beat }
522
+ local z = {}
523
+ for k = 1, 12 do z[k] = 0 end
524
+ profiles[i], bass[i] = z, -1
525
+ end
526
+ if p.midi then
527
+ local notes, c = heard_notes(api, tr, t0, t1)
528
+ for _, n in ipairs(notes) do
529
+ -- First segment the note can touch (segments are in time order)
530
+ local lo, hi = 1, #segs
531
+ while lo < hi do
532
+ local mid = (lo + hi) // 2
533
+ if segs[mid].q1 <= n.qs then lo = mid + 1 else hi = mid end
534
+ end
535
+ for i = lo, #segs do
536
+ local s = segs[i]
537
+ if s.q0 >= n.qe then break end
538
+ local o = math.min(n.qe, s.q1) - math.max(n.qs, s.q0)
539
+ if o > 0 then
540
+ local pc = n.pitch % 12 + 1
541
+ profiles[i][pc] = profiles[i][pc] + o * n.w
542
+ -- The bass: the lowest note held for at least a quarter of the segment
543
+ if o >= 0.25 * (s.q1 - s.q0) - EPS and (bass[i] < 0 or n.pitch < bass[i]) then bass[i] = n.pitch end
544
+ end
545
+ end
546
+ end
547
+ for i = 1, #segs do for k = 1, 12 do profiles[i][k] = r4(profiles[i][k]) end end
548
+ out.profiles, out.bass = profiles, bass
549
+ out.notes, out.midiItems, out.audioItems = c.notes, c.midiItems, c.audioItems
550
+ if c.capped > 0 then out.itemsNotRead = c.capped end
551
+ end
552
+ return out
553
+ end
554
+
555
+ return M