Arrowing cell by cell is right inside a dense bar and tedious across an empty one. Three pairs of keys now jump straight to a note that is really there, one pair for each axis: Alt+Up/Down the column -- next pitch sounding in this cell Alt+Left/Right the row -- next note at the cursor's pitch Ctrl+Alt+Left/Right anywhere -- next note in the take, any pitch plus Ctrl+Alt+Up/Down for whole-octave movement, which needs no scale awareness because an octave preserves the pitch class. All the jumps ignore muted notes, which are not audible content, and the row and any-note jumps anchor on note starts, so a note held across eight cells is one stop rather than eight. Landing in a chord takes whichever note is nearest the pitch you came from rather than always the bottom one. They sit on Ctrl+Alt rather than plain Ctrl because REAPER already uses Ctrl+arrows to move between items. The logic lives in the lib as usual, with four run* drivers following the runShift pattern, so the eight new action scripts are seven lines each. test_lib.lua previously dofile'd an absolute D:\ path and so could not run outside one machine; it now resolves the lib relative to itself. Stubbing MIDI_GetNote and the PPQ conversions reaches further than the old "pure logic only" boundary, so the new tests cover the search functions and the drivers' speech and cursor movement too.
989 lines
34 KiB
Lua
989 lines
34 KiB
Lua
--[[
|
|
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
|
|
|
|
------------------------------------------------------------- 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
|
|
|
|
function M.noteEndingAt(take, pitch, ppq)
|
|
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
|
|
|
|
function M.noteStartingAt(take, pitch, ppq)
|
|
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
|
|
|
|
------------------------------------------------------------ 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
|
|
|
|
--------------------------------------------------------------------- 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.
|
|
local pi, _, _ = M.noteEndingAt(take, pitch, ppqA)
|
|
local ni, nsppq, neppq = M.noteStartingAt(take, pitch, ppqB)
|
|
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
|
|
|
|
------------------------------------------------------------------ playback
|
|
|
|
-- Start and end of the bar containing time t, plus the 1-based bar number.
|
|
function M.barBounds(t)
|
|
local _, measures = reaper.TimeMap2_timeToBeats(0, t)
|
|
return reaper.TimeMap2_beatsToTime(0, 0, measures),
|
|
reaper.TimeMap2_beatsToTime(0, 0, measures + 1),
|
|
measures + 1
|
|
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.
|
|
]]
|
|
function M.playFromBar(stopAtBarEnd)
|
|
local here = reaper.GetCursorPosition()
|
|
local barPos, barEnd, barNum = M.barBounds(here)
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
return barNum
|
|
end
|
|
|
|
--[[
|
|
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
|
|
|
|
---------------------------------------------------------------- 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
|
|
|
|
--------------------------------------------------------------- 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
|
|
|
|
--------------------------------------------------------------------- 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
|
|
|
|
------------------------------------------------------------ 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
|
|
|
|
------------------------------------------------------------------ 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
|
|
-- Sound through the instrument of the track being edited, not through
|
|
-- whatever else happened to be armed.
|
|
M.routePreview(select(2, M.editor()))
|
|
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
|