2026-08-14 18:13:45 +02:00
|
|
|
--[[
|
|
|
|
|
midigrid_lib.lua -- shared library for the MIDI Grid quick-entry mode.
|
|
|
|
|
|
|
|
|
|
Design notes:
|
|
|
|
|
* No window, no gfx. All feedback goes through OSARA speech.
|
|
|
|
|
* The cursor is Reaper's OWN edit cursor (time) plus the MIDI editor's
|
|
|
|
|
active_note_row (pitch). We never keep a private cursor, so grid mode
|
|
|
|
|
and normal OSARA editing can be interleaved freely.
|
|
|
|
|
* Grid size is read from MIDI_GetGrid(), so the user's existing
|
|
|
|
|
grid-size keybindings (1-9 in the MIDI editor) drive it.
|
|
|
|
|
* Persistent state is only: active, hold, root, scale, vel, chan.
|
|
|
|
|
]]
|
|
|
|
|
|
|
|
|
|
local M = {}
|
|
|
|
|
|
|
|
|
|
local EXT = "midigrid"
|
|
|
|
|
|
|
|
|
|
--------------------------------------------------------------------- state
|
|
|
|
|
|
|
|
|
|
function M.get(k, default)
|
|
|
|
|
local v = reaper.GetExtState(EXT, k)
|
|
|
|
|
if v == "" then return default end
|
|
|
|
|
return v
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function M.getNum(k, default)
|
|
|
|
|
return tonumber(M.get(k, tostring(default))) or default
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function M.set(k, v)
|
|
|
|
|
reaper.SetExtState(EXT, k, tostring(v), true)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Transient state (preview requests, daemon heartbeat). Never persisted:
|
|
|
|
|
-- these change on every keypress and must not hit reaper.ini.
|
|
|
|
|
function M.setTemp(k, v) reaper.SetExtState(EXT, k, tostring(v), false) end
|
|
|
|
|
|
|
|
|
|
function M.getTemp(k, default)
|
|
|
|
|
local v = reaper.GetExtState(EXT, k)
|
|
|
|
|
if v == "" then return default end
|
|
|
|
|
return v
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function M.isActive() return M.get("active", "0") == "1" end
|
|
|
|
|
function M.isHold() return M.get("hold", "0") == "1" end
|
|
|
|
|
|
|
|
|
|
--------------------------------------------------------------------- speech
|
|
|
|
|
|
|
|
|
|
-- OSARA exposes osara_outputMessage; fall back to the console if absent so
|
|
|
|
|
-- the scripts are still debuggable on a machine without OSARA.
|
|
|
|
|
function M.say(msg)
|
|
|
|
|
if reaper.APIExists("osara_outputMessage") then
|
|
|
|
|
reaper.osara_outputMessage(msg)
|
|
|
|
|
else
|
|
|
|
|
reaper.ShowConsoleMsg(tostring(msg) .. "\n")
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
--------------------------------------------------------------- editor/take
|
|
|
|
|
|
|
|
|
|
-- Returns hwnd, take for the active MIDI editor, or nil plus a reason.
|
|
|
|
|
function M.editor()
|
|
|
|
|
local hwnd = reaper.MIDIEditor_GetActive()
|
|
|
|
|
if not hwnd then return nil, nil, "No MIDI editor open" end
|
|
|
|
|
local take = reaper.MIDIEditor_GetTake(hwnd)
|
|
|
|
|
if not take or not reaper.TakeIsMIDI(take) then
|
|
|
|
|
return nil, nil, "No MIDI take in editor"
|
|
|
|
|
end
|
|
|
|
|
return hwnd, take, nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Forward a keystroke to whatever it would normally do when grid mode is off.
|
|
|
|
|
-- Accepts a numeric command id or a named command string such as
|
|
|
|
|
-- "_OSARA_NEXTCHORD".
|
|
|
|
|
function M.passThrough(cmd)
|
|
|
|
|
local hwnd = reaper.MIDIEditor_GetActive()
|
|
|
|
|
local id = cmd
|
|
|
|
|
if type(cmd) == "string" then id = reaper.NamedCommandLookup(cmd) end
|
|
|
|
|
if not id or id == 0 then return false end
|
|
|
|
|
if hwnd then
|
|
|
|
|
reaper.MIDIEditor_OnCommand(hwnd, id)
|
|
|
|
|
else
|
|
|
|
|
reaper.Main_OnCommand(id, 0)
|
|
|
|
|
end
|
|
|
|
|
return true
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
----------------------------------------------------------------- note names
|
|
|
|
|
|
|
|
|
|
local PC_NAMES = { "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" }
|
|
|
|
|
|
|
|
|
|
-- Reaper's displayed octave depends on the "midioctoffs" preference so that
|
|
|
|
|
-- our speech matches what OSARA and the editor say. With the default offset
|
|
|
|
|
-- of 1, note 60 reads as C5.
|
|
|
|
|
local function octaveOffset()
|
|
|
|
|
if reaper.SNM_GetIntConfigVar then
|
|
|
|
|
return reaper.SNM_GetIntConfigVar("midioctoffs", 1)
|
|
|
|
|
end
|
|
|
|
|
return 1
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function M.noteName(pitch)
|
|
|
|
|
local pc = pitch % 12
|
|
|
|
|
local oct = math.floor(pitch / 12) - 1 + octaveOffset()
|
|
|
|
|
return PC_NAMES[pc + 1] .. tostring(oct)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
--------------------------------------------------------------------- scales
|
|
|
|
|
|
|
|
|
|
M.SCALES = {
|
|
|
|
|
{ name = "major", steps = { 0, 2, 4, 5, 7, 9, 11 } },
|
|
|
|
|
{ name = "natural minor", steps = { 0, 2, 3, 5, 7, 8, 10 } },
|
|
|
|
|
{ name = "harmonic minor", steps = { 0, 2, 3, 5, 7, 8, 11 } },
|
|
|
|
|
{ name = "melodic minor", steps = { 0, 2, 3, 5, 7, 9, 11 } },
|
|
|
|
|
{ name = "dorian", steps = { 0, 2, 3, 5, 7, 9, 10 } },
|
|
|
|
|
{ name = "phrygian", steps = { 0, 1, 3, 5, 7, 8, 10 } },
|
|
|
|
|
{ name = "lydian", steps = { 0, 2, 4, 6, 7, 9, 11 } },
|
|
|
|
|
{ name = "mixolydian", steps = { 0, 2, 4, 5, 7, 9, 10 } },
|
|
|
|
|
{ name = "locrian", steps = { 0, 1, 3, 5, 6, 8, 10 } },
|
|
|
|
|
{ name = "major pentatonic", steps = { 0, 2, 4, 7, 9 } },
|
|
|
|
|
{ name = "minor pentatonic", steps = { 0, 3, 5, 7, 10 } },
|
|
|
|
|
{ name = "blues", steps = { 0, 3, 5, 6, 7, 10 } },
|
|
|
|
|
{ name = "whole tone", steps = { 0, 2, 4, 6, 8, 10 } },
|
|
|
|
|
{ name = "chromatic", steps = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 } },
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function M.scaleIndex()
|
|
|
|
|
local n = M.getNum("scale", 1)
|
|
|
|
|
if n < 1 or n > #M.SCALES then n = 1 end
|
|
|
|
|
return math.floor(n)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function M.scaleRoot() return math.floor(M.getNum("root", 0)) % 12 end
|
|
|
|
|
|
|
|
|
|
function M.scaleName()
|
|
|
|
|
return M.noteNamePc(M.scaleRoot()) .. " " .. M.SCALES[M.scaleIndex()].name
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function M.noteNamePc(pc) return PC_NAMES[(pc % 12) + 1] end
|
|
|
|
|
|
|
|
|
|
-- Set of pitch classes belonging to the current scale.
|
|
|
|
|
local function scalePcSet()
|
|
|
|
|
local root = M.scaleRoot()
|
|
|
|
|
local set = {}
|
|
|
|
|
for _, s in ipairs(M.SCALES[M.scaleIndex()].steps) do
|
|
|
|
|
set[(root + s) % 12] = true
|
|
|
|
|
end
|
|
|
|
|
return set
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function M.inScale(pitch) return scalePcSet()[pitch % 12] == true end
|
|
|
|
|
|
|
|
|
|
-- Step to the next pitch of the current scale in direction dir (+1 / -1).
|
|
|
|
|
-- Notes already off-scale simply move to the nearest scale tone in that
|
|
|
|
|
-- direction, so foreign notes never trap the cursor.
|
|
|
|
|
function M.scaleStep(pitch, dir)
|
|
|
|
|
local set = scalePcSet()
|
|
|
|
|
local p = pitch
|
|
|
|
|
for _ = 1, 24 do
|
|
|
|
|
p = p + dir
|
|
|
|
|
if p < 0 or p > 127 then return nil end
|
|
|
|
|
if set[p % 12] then return p end
|
|
|
|
|
end
|
|
|
|
|
return nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
------------------------------------------------------------ cursor / pitch
|
|
|
|
|
|
|
|
|
|
function M.getPitch(hwnd)
|
|
|
|
|
local p = reaper.MIDIEditor_GetSetting_int(hwnd, "active_note_row")
|
|
|
|
|
if not p or p < 0 or p > 127 then p = 60 end
|
|
|
|
|
return p
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function M.setPitch(hwnd, pitch)
|
|
|
|
|
reaper.MIDIEditor_SetSetting_int(hwnd, "active_note_row", pitch)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
------------------------------------------------------------------ the grid
|
|
|
|
|
|
|
|
|
|
-- Grid length in quarter notes, as configured in the MIDI editor.
|
|
|
|
|
function M.gridQN(take)
|
|
|
|
|
local grid = reaper.MIDI_GetGrid(take)
|
|
|
|
|
if not grid or grid <= 0 then grid = 1 end
|
|
|
|
|
return grid
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Speak grid sizes as musical fractions ("1/16", "1 bar") rather than raw
|
|
|
|
|
-- quarter-note counts.
|
|
|
|
|
function M.gridLabel(g)
|
|
|
|
|
-- Length of one bar in quarter notes, at the cursor's time signature.
|
|
|
|
|
local num = 4
|
|
|
|
|
local ts_num, ts_den = reaper.TimeMap_GetTimeSigAtTime(0, reaper.GetCursorPosition())
|
|
|
|
|
if ts_num and ts_den and ts_den > 0 then num = ts_num * (4 / ts_den) end
|
|
|
|
|
if math.abs(g - num) < 1e-6 then return "1 bar" end
|
|
|
|
|
if g > num then
|
|
|
|
|
local bars = g / num
|
|
|
|
|
return ("%g bars"):format(bars)
|
|
|
|
|
end
|
|
|
|
|
local frac = 4 / g
|
|
|
|
|
if math.abs(frac - math.floor(frac + 0.5)) < 1e-6 then
|
|
|
|
|
return ("1/%d"):format(math.floor(frac + 0.5))
|
|
|
|
|
end
|
|
|
|
|
return ("%.3g QN"):format(g)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- The grid cell the edit cursor currently sits in.
|
|
|
|
|
-- Cells are aligned to the project timeline (QN 0), matching Reaper's own
|
|
|
|
|
-- grid lines rather than the item start.
|
|
|
|
|
-- Returns: startQN, endQN, cellIndex, gridQN
|
|
|
|
|
function M.cell(take)
|
|
|
|
|
local g = M.gridQN(take)
|
|
|
|
|
local qn = reaper.TimeMap2_timeToQN(0, reaper.GetCursorPosition())
|
|
|
|
|
local idx = math.floor(qn / g + 1e-9)
|
|
|
|
|
return idx * g, (idx + 1) * g, idx, g
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
function M.cellPPQ(take)
|
|
|
|
|
local a, b = M.cell(take)
|
|
|
|
|
return reaper.MIDI_GetPPQPosFromProjQN(take, a),
|
|
|
|
|
reaper.MIDI_GetPPQPosFromProjQN(take, b)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Move the edit cursor to the start of cell index idx.
|
|
|
|
|
function M.gotoCell(take, idx)
|
|
|
|
|
local g = M.gridQN(take)
|
|
|
|
|
local t = reaper.TimeMap2_QNToTime(0, idx * g)
|
|
|
|
|
if t < 0 then t = 0 end
|
|
|
|
|
reaper.SetEditCurPos(t, true, false)
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-24 01:21:21 +02:00
|
|
|
--[[
|
|
|
|
|
Put the cursor on the nearest cell boundary to time t (the edit cursor by
|
|
|
|
|
default), without leaving the item.
|
|
|
|
|
|
|
|
|
|
Entering grid mode uses this instead of jumping to the item start: wherever
|
|
|
|
|
you were already listening stays where you are, only tidied onto a real cell
|
|
|
|
|
so the first left/right press moves a whole cell rather than a remainder.
|
|
|
|
|
|
|
|
|
|
Returns the cell index landed on.
|
|
|
|
|
]]
|
|
|
|
|
function M.snapCursorToCell(take, t)
|
|
|
|
|
local g = M.gridQN(take)
|
|
|
|
|
local item = reaper.GetMediaItemTake_Item(take)
|
|
|
|
|
local lo = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
|
|
|
|
|
local hi = lo + reaper.GetMediaItemInfo_Value(item, "D_LENGTH")
|
|
|
|
|
|
|
|
|
|
t = t or reaper.GetCursorPosition()
|
|
|
|
|
local idx = math.floor(reaper.TimeMap2_timeToQN(0, t) / g + 0.5)
|
|
|
|
|
|
|
|
|
|
-- A cursor outside the item pulls back to the first or last cell the item
|
|
|
|
|
-- covers. The first is the cell containing the item start, which is where
|
|
|
|
|
-- plain "open in editor" used to land.
|
|
|
|
|
local loIdx = math.floor(reaper.TimeMap2_timeToQN(0, lo) / g + 1e-9)
|
|
|
|
|
local hiIdx = math.ceil(reaper.TimeMap2_timeToQN(0, hi) / g - 1e-9) - 1
|
|
|
|
|
if hiIdx < loIdx then hiIdx = loIdx end
|
|
|
|
|
if idx < loIdx then idx = loIdx elseif idx > hiIdx then idx = hiIdx end
|
|
|
|
|
|
|
|
|
|
M.gotoCell(take, idx)
|
|
|
|
|
return idx
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-14 18:13:45 +02:00
|
|
|
------------------------------------------------------------- note queries
|
|
|
|
|
|
|
|
|
|
-- A cell counts as occupied by a note if the note overlaps it at all, so
|
|
|
|
|
-- held notes spanning several cells register in every cell they cover.
|
|
|
|
|
-- The 1-tick slack keeps float rounding at cell boundaries from producing
|
|
|
|
|
-- phantom overlaps.
|
|
|
|
|
local SLACK = 1
|
|
|
|
|
|
|
|
|
|
function M.noteInCell(take, pitch, ppqA, ppqB)
|
|
|
|
|
local _, notecnt = reaper.MIDI_CountEvts(take)
|
|
|
|
|
for i = 0, notecnt - 1 do
|
|
|
|
|
local ok, sel, muted, sppq, eppq, chan, p, vel = reaper.MIDI_GetNote(take, i)
|
|
|
|
|
if ok and p == pitch and sppq < ppqB - SLACK and eppq > ppqA + SLACK then
|
|
|
|
|
return i, sppq, eppq, chan, vel, sel, muted
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
return nil
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-14 18:27:14 +02:00
|
|
|
function M.noteEndingAt(take, pitch, ppq)
|
2026-08-14 18:13:45 +02:00
|
|
|
local _, notecnt = reaper.MIDI_CountEvts(take)
|
|
|
|
|
for i = 0, notecnt - 1 do
|
|
|
|
|
local ok, _, _, sppq, eppq, _, p = reaper.MIDI_GetNote(take, i)
|
|
|
|
|
if ok and p == pitch and math.abs(eppq - ppq) <= SLACK then return i, sppq, eppq end
|
|
|
|
|
end
|
|
|
|
|
return nil
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-14 18:27:14 +02:00
|
|
|
function M.noteStartingAt(take, pitch, ppq)
|
2026-08-14 18:13:45 +02:00
|
|
|
local _, notecnt = reaper.MIDI_CountEvts(take)
|
|
|
|
|
for i = 0, notecnt - 1 do
|
|
|
|
|
local ok, _, _, sppq, eppq, _, p = reaper.MIDI_GetNote(take, i)
|
|
|
|
|
if ok and p == pitch and math.abs(sppq - ppq) <= SLACK then return i, sppq, eppq end
|
|
|
|
|
end
|
|
|
|
|
return nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- All pitches sounding anywhere inside the current cell, low to high.
|
|
|
|
|
function M.pitchesInCell(take, ppqA, ppqB)
|
|
|
|
|
local out = {}
|
|
|
|
|
local _, notecnt = reaper.MIDI_CountEvts(take)
|
|
|
|
|
for i = 0, notecnt - 1 do
|
|
|
|
|
local ok, _, muted, sppq, eppq, _, p = reaper.MIDI_GetNote(take, i)
|
|
|
|
|
if ok and not muted and sppq < ppqB - SLACK and eppq > ppqA + SLACK then
|
|
|
|
|
out[#out + 1] = p
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
table.sort(out)
|
|
|
|
|
-- de-duplicate unisons on different channels
|
|
|
|
|
local uniq = {}
|
|
|
|
|
for _, p in ipairs(out) do
|
|
|
|
|
if uniq[#uniq] ~= p then uniq[#uniq + 1] = p end
|
|
|
|
|
end
|
|
|
|
|
return uniq
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-24 01:15:57 +02:00
|
|
|
------------------------------------------------------------ octave jumps
|
|
|
|
|
|
|
|
|
|
-- Jump a whole octave, keeping the pitch class. Because the octave is
|
|
|
|
|
-- transpositionally neutral, a scale tone stays a scale tone, so this needs
|
|
|
|
|
-- no scale awareness at all -- unlike scaleStep it just adds 12.
|
|
|
|
|
-- Returns nil at the ends of the MIDI range rather than clamping, so the
|
|
|
|
|
-- caller can say "top" instead of pretending it moved.
|
|
|
|
|
function M.octaveStep(pitch, dir)
|
|
|
|
|
local p = pitch + 12 * dir
|
|
|
|
|
if p < 0 or p > 127 then return nil end
|
|
|
|
|
return p
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
------------------------------------------------------ jumping to real notes
|
|
|
|
|
|
|
|
|
|
--[[
|
|
|
|
|
Arrowing cell by cell is right inside a dense bar and tedious across an
|
|
|
|
|
empty one. These jump straight to a note that is actually there, along the
|
|
|
|
|
three axes you can be looking down:
|
|
|
|
|
|
|
|
|
|
column -- the pitches sounding in the cell you are on, up or down
|
|
|
|
|
row -- the same pitch, forwards or backwards in time
|
|
|
|
|
any -- the next note in the take whatever its pitch
|
|
|
|
|
|
|
|
|
|
Two rules they all share. They ignore muted notes, because a muted note is
|
|
|
|
|
not audible content and stopping on one would be a lie. And, apart from the
|
|
|
|
|
column jump, they anchor on note *starts*, so a note sustained over eight
|
|
|
|
|
cells is one stop rather than eight.
|
|
|
|
|
]]
|
|
|
|
|
|
|
|
|
|
-- Nearest pitch sounding in the cell strictly above (dir 1) or below (dir -1)
|
|
|
|
|
-- `pitch`. The cell's pitches arrive sorted, so the first match in the right
|
|
|
|
|
-- direction is the nearest one.
|
|
|
|
|
function M.pitchInCellToward(take, ppqA, ppqB, pitch, dir)
|
|
|
|
|
local ps = M.pitchesInCell(take, ppqA, ppqB)
|
|
|
|
|
if dir > 0 then
|
|
|
|
|
for i = 1, #ps do if ps[i] > pitch then return ps[i] end end
|
|
|
|
|
else
|
|
|
|
|
for i = #ps, 1, -1 do if ps[i] < pitch then return ps[i] end end
|
|
|
|
|
end
|
|
|
|
|
return nil
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Nearest note start beyond the current cell in direction dir.
|
|
|
|
|
-- opts.pitch -- restrict to this one pitch (row navigation)
|
|
|
|
|
-- opts.near -- when several notes share the winning position, prefer the
|
|
|
|
|
-- one closest to this pitch, so jumping into a chord lands
|
|
|
|
|
-- where you were rather than at its bottom
|
|
|
|
|
-- Returns startPPQ, pitch, or nil when there is nothing that way.
|
|
|
|
|
function M.nextNoteStart(take, ppqA, ppqB, dir, opts)
|
|
|
|
|
opts = opts or {}
|
|
|
|
|
local bestPos, bestPitch
|
|
|
|
|
local _, notecnt = reaper.MIDI_CountEvts(take)
|
|
|
|
|
|
|
|
|
|
for i = 0, notecnt - 1 do
|
|
|
|
|
local ok, _, muted, sppq, _, _, p = reaper.MIDI_GetNote(take, i)
|
|
|
|
|
local wanted = ok and not muted and (not opts.pitch or p == opts.pitch)
|
|
|
|
|
local beyond = wanted and
|
|
|
|
|
(dir > 0 and sppq > ppqB - SLACK or dir < 0 and sppq < ppqA - SLACK)
|
|
|
|
|
|
|
|
|
|
if beyond then
|
|
|
|
|
local better
|
|
|
|
|
if not bestPos then
|
|
|
|
|
better = true
|
|
|
|
|
elseif math.abs(sppq - bestPos) <= SLACK then
|
|
|
|
|
-- Same position: a chord. Break the tie towards opts.near.
|
|
|
|
|
better = opts.near ~= nil and
|
|
|
|
|
math.abs(p - opts.near) < math.abs(bestPitch - opts.near)
|
|
|
|
|
elseif dir > 0 then
|
|
|
|
|
better = sppq < bestPos
|
|
|
|
|
else
|
|
|
|
|
better = sppq > bestPos
|
|
|
|
|
end
|
|
|
|
|
if better then bestPos, bestPitch = sppq, p end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return bestPos, bestPitch
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Which grid cell a PPQ position falls in. Converted through quarter notes,
|
|
|
|
|
-- like everything else here, so it survives tempo changes.
|
|
|
|
|
function M.cellIndexAtPPQ(take, ppq)
|
|
|
|
|
local g = M.gridQN(take)
|
|
|
|
|
local qn = reaper.MIDI_GetProjQNFromPPQPos(take, ppq)
|
|
|
|
|
return math.floor(qn / g + 1e-9)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Speak and sound the cell the cursor has landed in, naming the pitch cursor
|
|
|
|
|
-- as well when the cell holds more than one note and the answer is therefore
|
|
|
|
|
-- not obvious.
|
|
|
|
|
local function announceCell(take, pitch)
|
|
|
|
|
local ppqA, ppqB = M.cellPPQ(take)
|
|
|
|
|
local ps = M.pitchesInCell(take, ppqA, ppqB)
|
|
|
|
|
local msg = M.cellPosText(take) .. ", " .. M.cellContentText(take)
|
|
|
|
|
if pitch and #ps > 1 then msg = msg .. ", cursor " .. M.noteName(pitch) end
|
|
|
|
|
M.say(msg)
|
|
|
|
|
M.preview(ps)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Shared driver for the two octave actions.
|
|
|
|
|
function M.runOctave(dir)
|
|
|
|
|
local hwnd, take, err = M.editor()
|
|
|
|
|
if not take then M.say(err) return end
|
|
|
|
|
|
|
|
|
|
local p = M.octaveStep(M.getPitch(hwnd), dir)
|
|
|
|
|
if not p then M.say(dir > 0 and "top" or "bottom") return end
|
|
|
|
|
M.setPitch(hwnd, p)
|
|
|
|
|
|
|
|
|
|
local ppqA, ppqB = M.cellPPQ(take)
|
|
|
|
|
local on = M.noteInCell(take, p, ppqA, ppqB) ~= nil
|
|
|
|
|
M.say(M.noteName(p) .. (on and ", on" or ""))
|
|
|
|
|
M.preview({ p })
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Shared driver for the two column actions: next note up or down within the
|
|
|
|
|
-- cell the cursor is already on. Time does not move.
|
|
|
|
|
function M.runColumnJump(dir)
|
|
|
|
|
local hwnd, take, err = M.editor()
|
|
|
|
|
if not take then M.say(err) return end
|
|
|
|
|
|
|
|
|
|
local ppqA, ppqB = M.cellPPQ(take)
|
|
|
|
|
local p = M.pitchInCellToward(take, ppqA, ppqB, M.getPitch(hwnd), dir)
|
|
|
|
|
if not p then
|
|
|
|
|
M.say(dir > 0 and "No note above in this cell" or "No note below in this cell")
|
|
|
|
|
return
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
M.setPitch(hwnd, p)
|
|
|
|
|
M.say(M.noteName(p) .. ", on")
|
|
|
|
|
M.preview({ p })
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Shared driver for the two row actions: next note at the cursor's own pitch,
|
|
|
|
|
-- forwards or backwards. The pitch cursor does not move.
|
|
|
|
|
function M.runRowJump(dir)
|
|
|
|
|
local hwnd, take, err = M.editor()
|
|
|
|
|
if not take then M.say(err) return end
|
|
|
|
|
|
|
|
|
|
local pitch = M.getPitch(hwnd)
|
|
|
|
|
local ppqA, ppqB = M.cellPPQ(take)
|
|
|
|
|
local pos = M.nextNoteStart(take, ppqA, ppqB, dir, { pitch = pitch })
|
|
|
|
|
if not pos then
|
|
|
|
|
M.say(("No %s %s"):format(dir > 0 and "later" or "earlier", M.noteName(pitch)))
|
|
|
|
|
return
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
M.gotoCell(take, M.cellIndexAtPPQ(take, pos))
|
|
|
|
|
announceCell(take, pitch)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Shared driver for the two any-note actions: the next note anywhere in the
|
|
|
|
|
-- take, moving both cursors.
|
|
|
|
|
function M.runNoteJump(dir)
|
|
|
|
|
local hwnd, take, err = M.editor()
|
|
|
|
|
if not take then M.say(err) return end
|
|
|
|
|
|
|
|
|
|
local ppqA, ppqB = M.cellPPQ(take)
|
|
|
|
|
local pos, p = M.nextNoteStart(take, ppqA, ppqB, dir, { near = M.getPitch(hwnd) })
|
|
|
|
|
if not pos then
|
|
|
|
|
M.say(dir > 0 and "No later notes" or "No earlier notes")
|
|
|
|
|
return
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
M.setPitch(hwnd, p)
|
|
|
|
|
M.gotoCell(take, M.cellIndexAtPPQ(take, pos))
|
|
|
|
|
announceCell(take, p)
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-14 18:13:45 +02:00
|
|
|
--------------------------------------------------------------------- edits
|
|
|
|
|
|
|
|
|
|
-- Toggle a note on/off at (current cell, pitch).
|
|
|
|
|
-- Returns "on", "off" or nil, plus a describing word for speech.
|
|
|
|
|
function M.toggleCell(take, pitch)
|
|
|
|
|
local ppqA, ppqB = M.cellPPQ(take)
|
|
|
|
|
local idx, sppq, eppq, chan, vel = M.noteInCell(take, pitch, ppqA, ppqB)
|
|
|
|
|
|
|
|
|
|
reaper.Undo_BeginBlock2(0)
|
|
|
|
|
local result, detail
|
|
|
|
|
|
|
|
|
|
if idx then
|
|
|
|
|
-- Cell is occupied: carve this cell out of whatever covers it.
|
|
|
|
|
local startsInside = sppq >= ppqA - SLACK
|
|
|
|
|
local endsInside = eppq <= ppqB + SLACK
|
|
|
|
|
if startsInside and endsInside then
|
|
|
|
|
reaper.MIDI_DeleteNote(take, idx)
|
|
|
|
|
detail = "removed"
|
|
|
|
|
elseif startsInside then
|
|
|
|
|
-- held note begins here and continues: shorten from the front
|
|
|
|
|
reaper.MIDI_SetNote(take, idx, nil, nil, ppqB, nil, nil, nil, nil, true)
|
|
|
|
|
detail = "shortened"
|
|
|
|
|
elseif endsInside then
|
|
|
|
|
-- held note ends here: shorten from the back
|
|
|
|
|
reaper.MIDI_SetNote(take, idx, nil, nil, nil, ppqA, nil, nil, nil, true)
|
|
|
|
|
detail = "shortened"
|
|
|
|
|
else
|
|
|
|
|
-- cell sits mid-way through a held note: split it in two
|
|
|
|
|
reaper.MIDI_SetNote(take, idx, nil, nil, nil, ppqA, nil, nil, nil, true)
|
|
|
|
|
reaper.MIDI_InsertNote(take, false, false, ppqB, eppq, chan, pitch, vel, true)
|
|
|
|
|
detail = "split"
|
|
|
|
|
end
|
|
|
|
|
result = "off"
|
|
|
|
|
else
|
|
|
|
|
local v = math.floor(M.getNum("vel", 96))
|
|
|
|
|
local c = math.floor(M.getNum("chan", 0))
|
|
|
|
|
if M.isHold() then
|
|
|
|
|
-- Join with neighbours of the same pitch so runs of adjacent cells
|
|
|
|
|
-- become one sustained note rather than repeated attacks.
|
2026-08-14 18:27:14 +02:00
|
|
|
local pi, _, _ = M.noteEndingAt(take, pitch, ppqA)
|
|
|
|
|
local ni, nsppq, neppq = M.noteStartingAt(take, pitch, ppqB)
|
2026-08-14 18:13:45 +02:00
|
|
|
if pi and ni then
|
|
|
|
|
reaper.MIDI_SetNote(take, pi, nil, nil, nil, neppq, nil, nil, nil, true)
|
|
|
|
|
reaper.MIDI_DeleteNote(take, ni)
|
|
|
|
|
detail = "joined"
|
|
|
|
|
elseif pi then
|
|
|
|
|
reaper.MIDI_SetNote(take, pi, nil, nil, nil, ppqB, nil, nil, nil, true)
|
|
|
|
|
detail = "extended"
|
|
|
|
|
elseif ni then
|
|
|
|
|
reaper.MIDI_SetNote(take, ni, nil, nil, ppqA, nil, nil, nil, nil, true)
|
|
|
|
|
detail = "extended"
|
|
|
|
|
else
|
|
|
|
|
reaper.MIDI_InsertNote(take, false, false, ppqA, ppqB, c, pitch, v, true)
|
|
|
|
|
detail = "added"
|
|
|
|
|
end
|
|
|
|
|
else
|
|
|
|
|
reaper.MIDI_InsertNote(take, false, false, ppqA, ppqB, c, pitch, v, true)
|
|
|
|
|
detail = "added"
|
|
|
|
|
end
|
|
|
|
|
result = "on"
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
reaper.MIDI_Sort(take)
|
|
|
|
|
reaper.Undo_EndBlock2(0, "MIDI Grid: toggle " .. M.noteName(pitch), -1)
|
|
|
|
|
return result, detail
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-14 19:57:55 +02:00
|
|
|
------------------------------------------------------------------ playback
|
|
|
|
|
|
2026-08-14 20:04:20 +02:00
|
|
|
-- Start and end of the bar containing time t, plus the 1-based bar number.
|
|
|
|
|
function M.barBounds(t)
|
2026-08-14 19:57:55 +02:00
|
|
|
local _, measures = reaper.TimeMap2_timeToBeats(0, t)
|
2026-08-14 20:04:20 +02:00
|
|
|
return reaper.TimeMap2_beatsToTime(0, 0, measures),
|
|
|
|
|
reaper.TimeMap2_beatsToTime(0, 0, measures + 1),
|
|
|
|
|
measures + 1
|
2026-08-14 19:57:55 +02:00
|
|
|
end
|
|
|
|
|
|
|
|
|
|
--[[
|
|
|
|
|
Play from the top of the bar the cursor is in, without losing your place.
|
|
|
|
|
|
|
|
|
|
The edit cursor doubles as the grid cursor, so it is moved to the bar line
|
|
|
|
|
only long enough to start playback and then put straight back. Restoring it
|
|
|
|
|
passes seekplay=false so the restore cannot drag playback along with it.
|
|
|
|
|
|
|
|
|
|
Playing from the bar line rather than the cursor is what makes fine
|
|
|
|
|
subdivisions checkable: you hear the note in relation to the downbeat,
|
|
|
|
|
which is the thing you are actually trying to judge.
|
|
|
|
|
]]
|
2026-08-14 20:04:20 +02:00
|
|
|
function M.playFromBar(stopAtBarEnd)
|
2026-08-14 19:57:55 +02:00
|
|
|
local here = reaper.GetCursorPosition()
|
2026-08-14 20:04:20 +02:00
|
|
|
local barPos, barEnd, barNum = M.barBounds(here)
|
2026-08-14 19:57:55 +02:00
|
|
|
|
|
|
|
|
reaper.PreventUIRefresh(1)
|
|
|
|
|
reaper.SetEditCurPos(barPos, false, true)
|
|
|
|
|
-- GetPlayState bit 0 is "playing"; avoid bitwise ops for portability.
|
|
|
|
|
if (reaper.GetPlayState() % 2) == 0 then
|
|
|
|
|
reaper.CSurf_OnPlay()
|
|
|
|
|
end
|
|
|
|
|
reaper.SetEditCurPos(here, false, false)
|
|
|
|
|
reaper.PreventUIRefresh(-1)
|
|
|
|
|
|
2026-08-14 20:04:20 +02:00
|
|
|
if stopAtBarEnd then
|
|
|
|
|
-- The daemon watches for this and stops transport, because an action
|
|
|
|
|
-- script must never stay alive to wait for it.
|
|
|
|
|
M.setTemp("stopat", barEnd)
|
|
|
|
|
-- Grace period: play state takes a moment to report as playing, and
|
|
|
|
|
-- without this the daemon would see "not playing" and cancel instantly.
|
|
|
|
|
M.setTemp("stoparm", reaper.time_precise() + 0.5)
|
|
|
|
|
M.ensureDaemon()
|
|
|
|
|
else
|
|
|
|
|
M.setTemp("stopat", "")
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-14 19:57:55 +02:00
|
|
|
return barNum
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-14 20:43:24 +02:00
|
|
|
--[[
|
|
|
|
|
Loop the cursor's bar.
|
|
|
|
|
|
|
|
|
|
Where the play-once action stops by polling -- and so can only be as precise
|
|
|
|
|
as its polling interval -- this hands the boundary to REAPER's audio engine
|
|
|
|
|
via the loop points, which is sample accurate. The next bar's downbeat can
|
|
|
|
|
never leak in, at any tempo or grid size.
|
|
|
|
|
|
|
|
|
|
The trade is that it repeats until you stop it with Space, rather than
|
|
|
|
|
stopping itself. For checking placement that is arguably better: leave it
|
|
|
|
|
looping and edit while it plays.
|
|
|
|
|
|
|
|
|
|
Loop range and repeat state are saved and restored by the daemon once
|
|
|
|
|
transport stops, so this does not quietly rearrange the project.
|
|
|
|
|
]]
|
|
|
|
|
function M.loopBar()
|
|
|
|
|
local here = reaper.GetCursorPosition()
|
|
|
|
|
local barPos, barEnd, barNum = M.barBounds(here)
|
|
|
|
|
|
|
|
|
|
local s, e = reaper.GetSet_LoopTimeRange(false, true, 0, 0, false)
|
|
|
|
|
M.setTemp("savedloop", ("%.17g,%.17g"):format(s, e))
|
|
|
|
|
M.setTemp("savedrep", reaper.GetSetRepeat(-1))
|
|
|
|
|
M.setTemp("stoparm", reaper.time_precise() + 0.5)
|
|
|
|
|
M.setTemp("stopat", "") -- cancel any pending play-once stop
|
|
|
|
|
|
|
|
|
|
reaper.PreventUIRefresh(1)
|
|
|
|
|
reaper.GetSet_LoopTimeRange(true, true, barPos, barEnd, false)
|
|
|
|
|
reaper.GetSetRepeat(1)
|
|
|
|
|
reaper.SetEditCurPos(barPos, false, true)
|
|
|
|
|
if (reaper.GetPlayState() % 2) == 0 then
|
|
|
|
|
reaper.CSurf_OnPlay()
|
|
|
|
|
end
|
|
|
|
|
reaper.SetEditCurPos(here, false, false)
|
|
|
|
|
reaper.PreventUIRefresh(-1)
|
|
|
|
|
|
|
|
|
|
M.ensureDaemon()
|
|
|
|
|
return barNum
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-14 18:46:33 +02:00
|
|
|
---------------------------------------------------------------- time shift
|
|
|
|
|
|
|
|
|
|
--[[
|
|
|
|
|
Move every note in the take by whole grid cells.
|
|
|
|
|
|
|
|
|
|
Because the entire take moves together, notes cannot collide with each
|
|
|
|
|
other -- the only thing at stake is the item's boundaries. Notes pushed
|
|
|
|
|
outside them are not deleted, they simply stop sounding, and shifting back
|
|
|
|
|
the other way restores them. The safe variant still refuses, because
|
|
|
|
|
silently silencing part of a clip is exactly the kind of thing you want to
|
|
|
|
|
be told about rather than discover later.
|
|
|
|
|
|
|
|
|
|
Positions are converted through QN rather than offset in ticks, so a shift
|
|
|
|
|
stays musically correct across tempo changes.
|
|
|
|
|
|
|
|
|
|
Returns moved, outside. moved is nil when the shift was refused.
|
|
|
|
|
]]
|
|
|
|
|
function M.shiftAllNotes(take, cells, force)
|
|
|
|
|
local _, notecnt = reaper.MIDI_CountEvts(take)
|
|
|
|
|
if notecnt == 0 then return 0, 0 end
|
|
|
|
|
|
|
|
|
|
local g = M.gridQN(take)
|
|
|
|
|
local item = reaper.GetMediaItemTake_Item(take)
|
|
|
|
|
local itemPos = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
|
|
|
|
|
local itemLen = reaper.GetMediaItemInfo_Value(item, "D_LENGTH")
|
|
|
|
|
local loPPQ = reaper.MIDI_GetPPQPosFromProjTime(take, itemPos)
|
|
|
|
|
local hiPPQ = reaper.MIDI_GetPPQPosFromProjTime(take, itemPos + itemLen)
|
|
|
|
|
|
|
|
|
|
local moved, outside = {}, 0
|
|
|
|
|
for i = 0, notecnt - 1 do
|
|
|
|
|
local ok, _, _, s, e = reaper.MIDI_GetNote(take, i)
|
|
|
|
|
if ok then
|
|
|
|
|
local ns = reaper.MIDI_GetPPQPosFromProjQN(take,
|
|
|
|
|
reaper.MIDI_GetProjQNFromPPQPos(take, s) + cells * g)
|
|
|
|
|
local ne = reaper.MIDI_GetPPQPosFromProjQN(take,
|
|
|
|
|
reaper.MIDI_GetProjQNFromPPQPos(take, e) + cells * g)
|
|
|
|
|
moved[i] = { ns, ne }
|
|
|
|
|
if ns < loPPQ - SLACK or ne > hiPPQ + SLACK then outside = outside + 1 end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
if outside > 0 and not force then return nil, outside end
|
|
|
|
|
|
|
|
|
|
reaper.Undo_BeginBlock2(0)
|
|
|
|
|
for i = 0, notecnt - 1 do
|
|
|
|
|
local np = moved[i]
|
|
|
|
|
if np then
|
|
|
|
|
reaper.MIDI_SetNote(take, i, nil, nil, np[1], np[2], nil, nil, nil, true)
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
reaper.MIDI_Sort(take)
|
|
|
|
|
reaper.Undo_EndBlock2(0, "MIDI Grid: shift notes", -1)
|
|
|
|
|
|
|
|
|
|
return notecnt, outside
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Shared driver for the four shift actions: does the work and the speech.
|
|
|
|
|
function M.runShift(cells, force)
|
|
|
|
|
local hwnd, take, err = M.editor()
|
|
|
|
|
if not take then M.say(err) return end
|
|
|
|
|
|
|
|
|
|
local moved, outside = M.shiftAllNotes(take, cells, force)
|
|
|
|
|
local dir = cells < 0 and "left" or "right"
|
|
|
|
|
|
|
|
|
|
if moved == nil then
|
|
|
|
|
M.say(("Not shifted %s, %d %s would fall outside the item. Add Alt to force.")
|
|
|
|
|
:format(dir, outside, outside == 1 and "note" or "notes"))
|
|
|
|
|
elseif moved == 0 then
|
|
|
|
|
M.say("No notes to shift")
|
|
|
|
|
elseif outside > 0 then
|
|
|
|
|
M.say(("Shifted %s, %d %s now outside the item and silent")
|
|
|
|
|
:format(dir, outside, outside == 1 and "note" or "notes"))
|
|
|
|
|
else
|
|
|
|
|
M.say(("Shifted %s, %d %s"):format(dir, moved, moved == 1 and "note" or "notes"))
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-14 18:27:14 +02:00
|
|
|
--------------------------------------------------------------- note length
|
|
|
|
|
|
|
|
|
|
--[[
|
|
|
|
|
Grow or shrink the note under the cursor by whole grid cells.
|
|
|
|
|
|
|
|
|
|
This is how you sustain notes that already exist. It is deliberately
|
|
|
|
|
independent of hold mode -- hold mode governs how *new* notes join as you
|
|
|
|
|
enter them, whereas this edits an existing note's length in place, so it
|
|
|
|
|
preserves velocity, channel and note identity. Toggling a note off and on
|
|
|
|
|
again to make it join would silently reset its velocity to the default.
|
|
|
|
|
|
|
|
|
|
Extending swallows any same-pitch notes it runs into, absorbing their tail
|
|
|
|
|
if they reach further than the new end, so growing a note across repeated
|
|
|
|
|
notes fuses the run rather than producing overlaps.
|
|
|
|
|
]]
|
|
|
|
|
function M.resizeNote(take, pitch, cells)
|
|
|
|
|
local ppqA, ppqB = M.cellPPQ(take)
|
|
|
|
|
local idx, sppq, eppq = M.noteInCell(take, pitch, ppqA, ppqB)
|
|
|
|
|
if not idx then return nil end
|
|
|
|
|
|
|
|
|
|
local g = M.gridQN(take)
|
|
|
|
|
local startQN = reaper.MIDI_GetProjQNFromPPQPos(take, sppq)
|
|
|
|
|
local endQN = reaper.MIDI_GetProjQNFromPPQPos(take, eppq)
|
|
|
|
|
|
|
|
|
|
local newEndQN = endQN + cells * g
|
|
|
|
|
local clamped = false
|
|
|
|
|
if newEndQN < startQN + g - 1e-9 then
|
|
|
|
|
newEndQN = startQN + g -- never shrink below a single cell
|
|
|
|
|
clamped = true
|
|
|
|
|
end
|
|
|
|
|
local newEnd = reaper.MIDI_GetPPQPosFromProjQN(take, newEndQN)
|
|
|
|
|
|
|
|
|
|
reaper.Undo_BeginBlock2(0)
|
|
|
|
|
|
|
|
|
|
local absorbed = 0
|
|
|
|
|
if cells > 0 then
|
|
|
|
|
-- Collect first, delete descending: deleting shifts later indices.
|
|
|
|
|
local victims = {}
|
|
|
|
|
local _, notecnt = reaper.MIDI_CountEvts(take)
|
|
|
|
|
for i = 0, notecnt - 1 do
|
|
|
|
|
local ok, _, _, s, e, _, p = reaper.MIDI_GetNote(take, i)
|
|
|
|
|
if ok and p == pitch and i ~= idx and s > sppq + SLACK and s < newEnd - SLACK then
|
|
|
|
|
victims[#victims + 1] = i
|
|
|
|
|
if e > newEnd then newEnd = e end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
table.sort(victims, function(a, b) return a > b end)
|
|
|
|
|
for _, i in ipairs(victims) do
|
|
|
|
|
reaper.MIDI_DeleteNote(take, i)
|
|
|
|
|
if i < idx then idx = idx - 1 end
|
|
|
|
|
absorbed = absorbed + 1
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
reaper.MIDI_SetNote(take, idx, nil, nil, nil, newEnd, nil, nil, nil, true)
|
|
|
|
|
reaper.MIDI_Sort(take)
|
|
|
|
|
reaper.Undo_EndBlock2(0, "MIDI Grid: resize note", -1)
|
|
|
|
|
|
|
|
|
|
local lenCells = (reaper.MIDI_GetProjQNFromPPQPos(take, newEnd) - startQN) / g
|
|
|
|
|
return math.floor(lenCells + 0.5), absorbed, clamped
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Fuse every contiguous same-pitch note touching the one under the cursor
|
|
|
|
|
-- into a single sustained note. Returns how many notes were merged.
|
|
|
|
|
function M.joinRun(take, pitch)
|
|
|
|
|
local ppqA, ppqB = M.cellPPQ(take)
|
|
|
|
|
local idx, sppq, eppq = M.noteInCell(take, pitch, ppqA, ppqB)
|
|
|
|
|
if not idx then return nil end
|
|
|
|
|
|
|
|
|
|
local minS, maxE = sppq, eppq
|
|
|
|
|
local victims = {}
|
|
|
|
|
local seen = { [idx] = true }
|
|
|
|
|
|
|
|
|
|
while true do
|
|
|
|
|
local i, s = M.noteEndingAt(take, pitch, minS)
|
|
|
|
|
if not i or seen[i] then break end
|
|
|
|
|
seen[i] = true ; victims[#victims + 1] = i ; minS = s
|
|
|
|
|
end
|
|
|
|
|
while true do
|
|
|
|
|
local i, _, e = M.noteStartingAt(take, pitch, maxE)
|
|
|
|
|
if not i or seen[i] then break end
|
|
|
|
|
seen[i] = true ; victims[#victims + 1] = i ; maxE = e
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
if #victims == 0 then return 0 end
|
|
|
|
|
|
|
|
|
|
reaper.Undo_BeginBlock2(0)
|
|
|
|
|
table.sort(victims, function(a, b) return a > b end)
|
|
|
|
|
for _, i in ipairs(victims) do reaper.MIDI_DeleteNote(take, i) end
|
|
|
|
|
-- Indices shifted; re-find the survivor by its unchanged start position.
|
|
|
|
|
local keep = M.noteStartingAt(take, pitch, sppq)
|
|
|
|
|
if keep then
|
|
|
|
|
reaper.MIDI_SetNote(take, keep, nil, nil, minS, maxE, nil, nil, nil, true)
|
|
|
|
|
end
|
|
|
|
|
reaper.MIDI_Sort(take)
|
|
|
|
|
reaper.Undo_EndBlock2(0, "MIDI Grid: join notes", -1)
|
|
|
|
|
|
|
|
|
|
return #victims + 1
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-14 18:18:12 +02:00
|
|
|
--------------------------------------------------------------------- chords
|
|
|
|
|
|
|
|
|
|
-- Stack `count` diatonic chord tones on `pitch`, skipping a scale degree
|
|
|
|
|
-- between each -- so in a major scale, degree 1 gives a major triad and
|
|
|
|
|
-- degree 2 a minor one, exactly as harmony expects. In the chromatic scale
|
|
|
|
|
-- this degenerates to stacked whole tones, which is arguably useless but at
|
|
|
|
|
-- least predictable.
|
|
|
|
|
function M.chordPitches(pitch, count)
|
|
|
|
|
local ps = { pitch }
|
|
|
|
|
local p = pitch
|
|
|
|
|
for _ = 2, count do
|
|
|
|
|
local a = M.scaleStep(p, 1); if not a then return nil end
|
|
|
|
|
local b = M.scaleStep(a, 1); if not b then return nil end
|
|
|
|
|
p = b
|
|
|
|
|
ps[#ps + 1] = p
|
|
|
|
|
end
|
|
|
|
|
return ps
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Toggle a whole chord in the current cell. If every tone is already
|
|
|
|
|
-- present the chord is removed; otherwise the missing tones are filled in.
|
|
|
|
|
-- Delegates to toggleCell so hold mode, splitting and trimming all behave
|
|
|
|
|
-- identically to single notes.
|
|
|
|
|
function M.toggleChord(take, pitches)
|
|
|
|
|
local ppqA, ppqB = M.cellPPQ(take)
|
|
|
|
|
local allPresent = true
|
|
|
|
|
for _, p in ipairs(pitches) do
|
|
|
|
|
if not M.noteInCell(take, p, ppqA, ppqB) then allPresent = false break end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
reaper.Undo_BeginBlock2(0)
|
|
|
|
|
local changed = {}
|
|
|
|
|
for _, p in ipairs(pitches) do
|
|
|
|
|
local here = M.noteInCell(take, p, ppqA, ppqB) ~= nil
|
|
|
|
|
if allPresent or not here then
|
|
|
|
|
M.toggleCell(take, p)
|
|
|
|
|
changed[#changed + 1] = p
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
reaper.Undo_EndBlock2(0, "MIDI Grid: toggle chord", -1)
|
|
|
|
|
return (allPresent and "off" or "on"), changed
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
------------------------------------------------------------------ velocity
|
|
|
|
|
|
|
|
|
|
-- Adjust velocity by delta. If a note sits at the cursor cell and pitch, its
|
|
|
|
|
-- velocity changes; otherwise the default for newly inserted notes does.
|
|
|
|
|
-- Returns "note" or "default", and the resulting value.
|
|
|
|
|
function M.nudgeVelocity(take, pitch, delta)
|
|
|
|
|
local ppqA, ppqB = M.cellPPQ(take)
|
|
|
|
|
local idx, _, _, _, vel = M.noteInCell(take, pitch, ppqA, ppqB)
|
|
|
|
|
if idx then
|
|
|
|
|
local v = math.max(1, math.min(127, math.floor(vel + delta)))
|
|
|
|
|
reaper.Undo_BeginBlock2(0)
|
|
|
|
|
reaper.MIDI_SetNote(take, idx, nil, nil, nil, nil, nil, nil, v, true)
|
|
|
|
|
reaper.MIDI_Sort(take)
|
|
|
|
|
reaper.Undo_EndBlock2(0, "MIDI Grid: set velocity", -1)
|
|
|
|
|
return "note", v
|
|
|
|
|
end
|
|
|
|
|
local v = math.max(1, math.min(127, math.floor(M.getNum("vel", 96) + delta)))
|
|
|
|
|
M.set("vel", v)
|
|
|
|
|
return "default", v
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-24 00:55:25 +02:00
|
|
|
------------------------------------------------------------ preview routing
|
|
|
|
|
|
|
|
|
|
--[[
|
|
|
|
|
Preview notes leave through StuffMIDIMessage, i.e. the virtual MIDI
|
|
|
|
|
keyboard, and Reaper feeds that to whatever tracks happen to be armed and
|
|
|
|
|
monitoring. That is usually NOT the track whose item you are editing, so
|
|
|
|
|
the grid would sound your notes through some unrelated instrument.
|
|
|
|
|
|
|
|
|
|
Grid mode therefore borrows the record routing: the edited take's track is
|
|
|
|
|
armed and monitoring with MIDI input, every other armed track is stood
|
|
|
|
|
down so it cannot answer as well, and the previous state of every track we
|
|
|
|
|
touched is remembered so it can be handed straight back.
|
|
|
|
|
|
|
|
|
|
The borrow is undone when grid mode is switched off, when the MIDI editor
|
|
|
|
|
closes, and on Reaper exit -- all three by the daemon, in the same way it
|
|
|
|
|
restores the loop/repeat state the loop-bar action borrows.
|
|
|
|
|
]]
|
|
|
|
|
|
|
|
|
|
-- "All MIDI inputs, all channels": 4096 | (device << 5) | channel, with
|
|
|
|
|
-- device 63 meaning all. Anything narrower may exclude the virtual keyboard.
|
|
|
|
|
local ALL_MIDI_IN = 4096 + 63 * 32
|
|
|
|
|
|
|
|
|
|
local function trackByGUID(guid)
|
|
|
|
|
for i = 0, reaper.CountTracks(0) - 1 do
|
|
|
|
|
local tr = reaper.GetTrack(0, i)
|
|
|
|
|
if reaper.GetTrackGUID(tr) == guid then return tr end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local function armState(tr)
|
|
|
|
|
return ("%s,%d,%d,%d"):format(
|
|
|
|
|
reaper.GetTrackGUID(tr),
|
|
|
|
|
reaper.GetMediaTrackInfo_Value(tr, "I_RECARM"),
|
|
|
|
|
reaper.GetMediaTrackInfo_Value(tr, "I_RECMON"),
|
|
|
|
|
reaper.GetMediaTrackInfo_Value(tr, "I_RECINPUT"))
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Hand back every track's arm/monitor/input exactly as we found it.
|
|
|
|
|
function M.restoreRouting()
|
|
|
|
|
local saved = M.getTemp("savedarm", "")
|
|
|
|
|
M.setTemp("savedarm", "")
|
|
|
|
|
M.setTemp("routed", "")
|
|
|
|
|
if saved == "" then return false end
|
|
|
|
|
for guid, arm, mon, inp in saved:gmatch("({[^}]*}),(%-?%d+),(%-?%d+),(%-?%d+)") do
|
|
|
|
|
local tr = trackByGUID(guid)
|
|
|
|
|
if tr then
|
|
|
|
|
reaper.SetMediaTrackInfo_Value(tr, "I_RECARM", tonumber(arm))
|
|
|
|
|
reaper.SetMediaTrackInfo_Value(tr, "I_RECMON", tonumber(mon))
|
|
|
|
|
reaper.SetMediaTrackInfo_Value(tr, "I_RECINPUT", tonumber(inp))
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
return true
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Point preview at the track this take lives on. Cheap to call on every
|
|
|
|
|
-- keypress: it only touches the project when the target has changed.
|
|
|
|
|
function M.routePreview(take)
|
|
|
|
|
if not take then return end
|
|
|
|
|
local tr = reaper.GetMediaItemTake_Track(take)
|
|
|
|
|
if not tr then return end
|
|
|
|
|
local guid = reaper.GetTrackGUID(tr)
|
|
|
|
|
if M.getTemp("routed", "") == guid then return end
|
|
|
|
|
|
|
|
|
|
-- Give the previously borrowed track back before borrowing another.
|
|
|
|
|
M.restoreRouting()
|
|
|
|
|
|
|
|
|
|
local saved = {}
|
|
|
|
|
for i = 0, reaper.CountTracks(0) - 1 do
|
|
|
|
|
local t = reaper.GetTrack(0, i)
|
|
|
|
|
local armed = reaper.GetMediaTrackInfo_Value(t, "I_RECARM") == 1
|
|
|
|
|
if t == tr or armed then
|
|
|
|
|
saved[#saved + 1] = armState(t)
|
|
|
|
|
if t ~= tr then reaper.SetMediaTrackInfo_Value(t, "I_RECARM", 0) end
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
reaper.SetMediaTrackInfo_Value(tr, "I_RECINPUT", ALL_MIDI_IN)
|
|
|
|
|
reaper.SetMediaTrackInfo_Value(tr, "I_RECARM", 1)
|
|
|
|
|
reaper.SetMediaTrackInfo_Value(tr, "I_RECMON", 1)
|
|
|
|
|
|
|
|
|
|
M.setTemp("savedarm", table.concat(saved, ";"))
|
|
|
|
|
M.setTemp("routed", guid)
|
|
|
|
|
end
|
|
|
|
|
|
2026-08-14 18:13:45 +02:00
|
|
|
------------------------------------------------------------------ audition
|
|
|
|
|
|
|
|
|
|
--[[
|
|
|
|
|
Preview is handled by a separate always-running daemon script rather than
|
|
|
|
|
by deferring here. The action scripts must exit immediately: if one is
|
|
|
|
|
still alive when you press the same key again, Reaper puts up its
|
|
|
|
|
"script is already running" prompt, which makes fast key repeat impossible.
|
|
|
|
|
|
|
|
|
|
So preview() only posts a request and returns. The daemon owns all
|
|
|
|
|
note-on/note-off timing.
|
|
|
|
|
]]
|
|
|
|
|
function M.preview(pitches, dur)
|
|
|
|
|
if not pitches or #pitches == 0 then return end
|
2026-08-24 00:55:25 +02:00
|
|
|
-- Sound through the instrument of the track being edited, not through
|
|
|
|
|
-- whatever else happened to be armed.
|
|
|
|
|
M.routePreview(select(2, M.editor()))
|
2026-08-14 18:13:45 +02:00
|
|
|
M.setTemp("pitches", table.concat(pitches, ","))
|
|
|
|
|
M.setTemp("dur", dur or M.getNum("previewdur", 0.4))
|
|
|
|
|
M.setTemp("seq", (tonumber(M.getTemp("seq", "0")) or 0) + 1)
|
|
|
|
|
M.ensureDaemon()
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Start the preview daemon if it is not currently alive. The daemon stamps a
|
|
|
|
|
-- wall-clock heartbeat every cycle; anything older than a few seconds means
|
|
|
|
|
-- it is gone (Reaper restarted, script terminated) and we relaunch it.
|
|
|
|
|
function M.ensureDaemon()
|
|
|
|
|
local hb = tonumber(M.getTemp("hb", "0")) or 0
|
|
|
|
|
if os.time() - hb <= 3 then return end
|
|
|
|
|
local id = reaper.NamedCommandLookup("_RSmidigrid_daemon")
|
|
|
|
|
if id and id ~= 0 then reaper.Main_OnCommand(id, 0) end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
--------------------------------------------------------------------- speech
|
|
|
|
|
|
|
|
|
|
-- "bar 3 beat 2" style position for the current cell.
|
|
|
|
|
function M.cellPosText(take)
|
|
|
|
|
local a = select(1, M.cell(take))
|
|
|
|
|
local t = reaper.TimeMap2_QNToTime(0, a)
|
|
|
|
|
return reaper.format_timestr_pos(t, "", 2)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Describe the cell contents for speech, e.g. "C5, E5" or "empty".
|
|
|
|
|
function M.cellContentText(take)
|
|
|
|
|
local ppqA, ppqB = M.cellPPQ(take)
|
|
|
|
|
local ps = M.pitchesInCell(take, ppqA, ppqB)
|
|
|
|
|
if #ps == 0 then return "empty" end
|
|
|
|
|
local names = {}
|
|
|
|
|
for _, p in ipairs(ps) do names[#names + 1] = M.noteName(p) end
|
|
|
|
|
return table.concat(names, ", ")
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
return M
|