@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.
- package/README.md +19 -570
- package/bridge-ui/mcp_bridge.html +129 -0
- package/bridge-ui/mcp_bridge.webmanifest +16 -0
- package/bridge-ui/mcp_bridge_icon_192.png +0 -0
- package/bridge-ui/mcp_bridge_icon_512.png +0 -0
- package/bridge-ui/mcp_bridge_sw.js +35 -0
- package/claude/agents/plugin-researcher.md +188 -0
- package/claude/knowledge/reference/aesthetics-scoring.md +48 -0
- package/{knowledge → claude/knowledge}/reference/common-mistakes.md +1 -1
- package/claude/knowledge/reference/lorekit-setup.md +36 -0
- package/claude/knowledge/reference/memory-protocol.md +377 -0
- package/{knowledge → claude/knowledge}/reference/metering.md +5 -5
- package/claude/knowledge/reference/perceived-loudness.md +182 -0
- package/{knowledge → claude/knowledge}/workflows/editing.md +4 -0
- package/{knowledge → claude/knowledge}/workflows/gain-staging.md +21 -23
- package/{knowledge → claude/knowledge}/workflows/learn-plugin.md +15 -48
- package/{knowledge → claude/knowledge}/workflows/master-bus.md +13 -1
- package/claude/knowledge/workflows/section-levels.md +138 -0
- package/{knowledge → claude/knowledge}/workflows/session-prep.md +4 -0
- package/claude/knowledge/workflows/transient-lighting.md +52 -0
- package/claude/skills/critique/SKILL.md +131 -0
- package/claude/skills/editing/SKILL.md +281 -0
- package/claude/skills/learn-plugin/SKILL.md +210 -0
- package/claude/skills/mastering/SKILL.md +151 -0
- package/claude/skills/mix-memory/SKILL.md +140 -0
- package/claude/skills/mixer/SKILL.md +253 -0
- package/claude/skills/new-extension/SKILL.md +324 -0
- package/main.js +7843 -1940
- package/package.json +17 -15
- package/reaper/CLAUDE.md +22 -4
- package/reaper/mcp_actions_lib.lua +375 -0
- package/reaper/mcp_analyzer.jsfx +27 -12
- package/reaper/mcp_arrange_lib.lua +949 -0
- package/reaper/mcp_bridge.lua +2321 -758
- package/reaper/mcp_edit_views_lib.lua +1378 -0
- package/reaper/mcp_envelopes_lib.lua +748 -0
- package/reaper/mcp_extensions_lib.lua +55 -0
- package/reaper/mcp_fxstate_lib.lua +144 -0
- package/reaper/mcp_gain_trace.jsfx +126 -0
- package/reaper/mcp_items_lib.lua +877 -0
- package/reaper/mcp_lufs_meter.jsfx +16 -13
- package/reaper/mcp_midi_lib.lua +555 -0
- package/reaper/mcp_mixing_lib.lua +1021 -0
- package/reaper/mcp_plan_lib.lua +801 -0
- package/reaper/mcp_prefs_lib.lua +127 -0
- package/reaper/mcp_project_lib.lua +1088 -0
- package/reaper/mcp_recording_lib.lua +595 -0
- package/reaper/mcp_render_lib.lua +492 -0
- package/reaper/mcp_snapshot_lib.lua +547 -120
- package/reaper/mcp_snapshot_next.lua +10 -4
- package/reaper/mcp_snapshot_prev.lua +11 -4
- package/reaper/mcp_snapshot_quick_save.lua +7 -32
- package/reaper/mcp_snapshots_ui_lib.lua +258 -0
- package/reaper/mcp_tracks_lib.lua +409 -0
- package/sidecar/analysis.py +661 -0
- package/sidecar/chords.py +96 -0
- package/sidecar/mixcheck.py +295 -0
- package/sidecar/requirements-chords.txt +6 -0
- package/sidecar/requirements-separation.txt +3 -0
- package/sidecar/requirements.txt +17 -0
- package/sidecar/separation.py +142 -0
- package/sidecar/server.py +622 -0
- package/sidecar/tests/test_analysis.py +249 -0
- package/sidecar/tests/test_chords.py +117 -0
- package/sidecar/tests/test_mixcheck.py +163 -0
- package/sidecar/tests/test_separation.py +73 -0
- package/sidecar/tests/test_server.py +464 -0
- package/sidecar/tests/test_verify.py +243 -0
- package/sidecar/tests/test_voice_render.py +268 -0
- package/sidecar/tests/test_voice_sketch.py +140 -0
- package/sidecar/verify.py +224 -0
- package/sidecar/voice_render.py +295 -0
- package/sidecar/voice_sketch.py +280 -0
- package/claude-agents/critique.md +0 -147
- package/claude-agents/editor.md +0 -184
- package/claude-agents/levels.md +0 -107
- package/claude-agents/mastering.md +0 -129
- package/claude-agents/mixer.md +0 -248
- package/claude-agents/preflight.md +0 -149
- package/claude-agents/producer.md +0 -159
- package/claude-agents/setup.md +0 -150
- package/claude-agents/stems.md +0 -135
- package/claude-rules/architecture.md +0 -39
- package/claude-rules/development.md +0 -54
- package/claude-rules/lua-bridge.md +0 -50
- package/claude-rules/testing.md +0 -42
- package/claude-skills/learn-plugin.md +0 -123
- package/knowledge/CLAUDE.md +0 -71
- package/knowledge/plugins/_template.md +0 -82
- package/knowledge/plugins/fabfilter/pro-c-2.md +0 -117
- package/knowledge/plugins/fabfilter/pro-l-2.md +0 -95
- package/knowledge/plugins/fabfilter/pro-q-3.md +0 -112
- package/knowledge/plugins/neural-dsp/helix-native.md +0 -104
- package/knowledge/plugins/stock-reaper/js-1175-compressor.md +0 -94
- package/knowledge/plugins/stock-reaper/rea-comp.md +0 -100
- package/knowledge/plugins/stock-reaper/rea-delay.md +0 -95
- package/knowledge/plugins/stock-reaper/rea-eq.md +0 -103
- package/knowledge/plugins/stock-reaper/rea-gate.md +0 -99
- package/knowledge/plugins/stock-reaper/rea-limit.md +0 -75
- package/knowledge/plugins/stock-reaper/rea-verb.md +0 -76
- package/knowledge/reference/perceived-loudness.md +0 -122
- package/reaper/mcp_snapshot_manager.lua +0 -1001
- /package/{knowledge → claude/knowledge}/genres/_template.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/electronic.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/hip-hop.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/metal.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/orchestral.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/pop.md +0 -0
- /package/{knowledge → claude/knowledge}/genres/rock.md +0 -0
- /package/{knowledge → claude/knowledge}/reference/compression.md +0 -0
- /package/{knowledge → claude/knowledge}/reference/frequencies.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/delivery.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/drum-bus.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/low-end.md +0 -0
- /package/{knowledge → claude/knowledge}/workflows/stem-prep.md +0 -0
- /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,
|
|
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
|
-
|
|
37
|
-
|
|
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(
|
|
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
|
-
|
|
114
|
+
// Named sliders live in their variables; slider1/slider2 stay 0
|
|
115
|
+
reset_btn == 1 ? (
|
|
114
116
|
reset_measurement();
|
|
115
|
-
|
|
116
|
-
sliderchange(
|
|
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(
|
|
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
|
-
|
|
221
|
-
|
|
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
|
|
224
|
-
integrated_mean = integrated_count > 0 ? (integrated_sum /
|
|
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
|