Add octave jumps and jumping to notes that exist

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.
This commit is contained in:
2026-08-24 01:15:57 +02:00
parent 0acb7d58f6
commit c55ab24ae4
14 changed files with 495 additions and 3 deletions
+169
View File
@@ -285,6 +285,175 @@ function M.pitchesInCell(take, ppqA, ppqB)
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).