Add note length editing for sustaining already-entered notes

Hold mode only governs entry, so sustaining existing notes previously meant
toggling hold on and re-entering them. That works but deletes and recreates
each note, silently resetting velocity to the default.

L and Shift+L resize the note under the cursor by whole grid cells, editing it
in place so velocity, channel and note identity survive. They are independent
of hold mode, so there is no state to set up or unset. Alt+J fuses a whole run
of repeated notes at one pitch in a single keystroke.

Lengthening absorbs same-pitch notes it runs into, taking their tail if longer,
so growing across a repeated run fuses it instead of creating overlaps.

Also makes noteEndingAt/noteStartingAt public and fixes their two call sites in
toggleCell, which would otherwise have been nil-call errors in hold mode.
This commit is contained in:
2026-08-14 18:27:14 +02:00
parent c9e927ca3d
commit 542f59e0db
6 changed files with 200 additions and 6 deletions
+20
View File
@@ -0,0 +1,20 @@
-- MIDI Grid: fuse a run of repeated notes at this pitch into one held note
local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])")
local G = dofile(dir .. "midigrid_lib.lua")
if not G.isActive() then return end
local hwnd, take, err = G.editor()
if not take then G.say(err) return end
local p = G.getPitch(hwnd)
local n = G.joinRun(take, p)
if not n then
G.say("No note here")
elseif n == 0 then
G.say(G.noteName(p) .. ", nothing adjacent to join")
else
G.say(G.noteName(p) .. ", joined " .. n .. " notes")
G.preview({ p })
end
+17
View File
@@ -0,0 +1,17 @@
-- MIDI Grid: lengthen the note under the cursor by one grid cell
local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])")
local G = dofile(dir .. "midigrid_lib.lua")
if not G.isActive() then G.passThrough(40446) return end -- Edit: Lengthen notes one grid unit
local hwnd, take, err = G.editor()
if not take then G.say(err) return end
local p = G.getPitch(hwnd)
local cells, absorbed = G.resizeNote(take, p, 1)
if not cells then G.say("No note here") return end
local msg = G.noteName(p) .. ", " .. cells .. (cells == 1 and " cell" or " cells")
if absorbed > 0 then msg = msg .. ", joined " .. absorbed end
G.say(msg)
G.preview({ p })
+19
View File
@@ -0,0 +1,19 @@
-- MIDI Grid: shorten the note under the cursor by one grid cell
local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])")
local G = dofile(dir .. "midigrid_lib.lua")
if not G.isActive() then G.passThrough(40447) return end -- Edit: Shorten notes one grid unit
local hwnd, take, err = G.editor()
if not take then G.say(err) return end
local p = G.getPitch(hwnd)
local cells, _, clamped = G.resizeNote(take, p, -1)
if not cells then G.say("No note here") return end
if clamped then
G.say(G.noteName(p) .. ", 1 cell, minimum")
else
G.say(G.noteName(p) .. ", " .. cells .. (cells == 1 and " cell" or " cells"))
end
G.preview({ p })
+32
View File
@@ -72,6 +72,8 @@ actions by hand in Actions → Show action list. That always works.
| `Left` / `Right` | Cursor back / forward one grid cell | OSARA previous / next chord |
| `Up` / `Down` | Pitch up / down one scale degree | OSARA higher / lower note in chord |
| `Enter` | Toggle note at cursor cell | Edit: Event properties |
| `L` / `Shift+L` | Lengthen / shorten note by one cell | Edit: Lengthen / shorten notes one grid unit |
| `Alt+J` | Join a run of repeated notes into one | — |
| `Alt+3` | Toggle diatonic triad on cursor pitch | — |
| `Alt+7` | Toggle diatonic seventh chord | — |
| `Alt+Shift+Up` / `Alt+Shift+Down` | Velocity up / down | — |
@@ -115,6 +117,36 @@ it into one sustained note. Toggling a cell off in the middle of a sustained
run splits it in two; at either end, it shortens it. So you can draw a long
note by arrowing right and pressing Enter across several cells.
Hold mode governs **entry only** — it never retroactively merges notes that
are already there. To sustain what you have already written, use the length
keys below.
## Note length — sustaining notes you already entered
- **`L`** — lengthen the note under the cursor by one grid cell.
- **`Shift+L`** — shorten it by one cell. It never shrinks below a single
cell; it says *"minimum"* rather than deleting the note.
- **`Alt+J`** — fuse an entire run of repeated notes at this pitch into one
sustained note, in a single keystroke.
Lengthening **swallows** any same-pitch note it runs into, taking that note's
tail if it reached further. So pressing `L` across a row of repeated notes
fuses them rather than producing overlaps.
Each press reports the resulting length — *"E5, 3 cells"*, or *"E5, 2 cells,
joined 1"* when it absorbed something.
### Why not re-toggle with hold on instead?
Turning hold on and re-entering existing notes does work. But toggling a note
off and on **deletes and recreates it, resetting its velocity to the default**
— if you have shaped dynamics, that quietly discards them.
The length keys edit the note in place, so velocity, channel and note identity
survive. They also need no mode: `L` behaves identically whether hold is on or
off, so there is no state to set up beforehand and none to remember to turn
off afterwards.
## Chords
`Alt+3` stamps a **diatonic triad** on the cursor pitch; `Alt+7` a seventh
+8 -2
View File
@@ -52,7 +52,10 @@ $actions = @(
@('RSmidigrid_triad', 32060, 'MIDI Grid: Toggle diatonic triad', 'MidiGrid_ChordTriad.lua'),
@('RSmidigrid_seventh',32060, 'MIDI Grid: Toggle diatonic seventh chord', 'MidiGrid_ChordSeventh.lua'),
@('RSmidigrid_velup', 32060, 'MIDI Grid: Velocity up', 'MidiGrid_VelUp.lua'),
@('RSmidigrid_veldn', 32060, 'MIDI Grid: Velocity down', 'MidiGrid_VelDown.lua')
@('RSmidigrid_veldn', 32060, 'MIDI Grid: Velocity down', 'MidiGrid_VelDown.lua'),
@('RSmidigrid_longer', 32060, 'MIDI Grid: Lengthen note one cell', 'MidiGrid_Lengthen.lua'),
@('RSmidigrid_shorter',32060, 'MIDI Grid: Shorten note one cell', 'MidiGrid_Shorten.lua'),
@('RSmidigrid_join', 32060, 'MIDI Grid: Join run of repeated notes', 'MidiGrid_Join.lua')
)
# flags, keycode, section, id
@@ -72,7 +75,10 @@ $keys = @(
@(17, 51, 32060, 'RSmidigrid_triad'),
@(17, 55, 32060, 'RSmidigrid_seventh'),
@(21, 32806, 32060, 'RSmidigrid_velup'),
@(21, 32808, 32060, 'RSmidigrid_veldn')
@(21, 32808, 32060, 'RSmidigrid_veldn'),
@(1, 76, 32060, 'RSmidigrid_longer'),
@(5, 76, 32060, 'RSmidigrid_shorter'),
@(17, 74, 32060, 'RSmidigrid_join')
)
$backup = "$kb.midigrid-backup"
+104 -4
View File
@@ -248,7 +248,7 @@ function M.noteInCell(take, pitch, ppqA, ppqB)
return nil
end
local function noteEndingAt(take, pitch, ppq)
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)
@@ -257,7 +257,7 @@ local function noteEndingAt(take, pitch, ppq)
return nil
end
local function noteStartingAt(take, pitch, ppq)
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)
@@ -324,8 +324,8 @@ function M.toggleCell(take, pitch)
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, _, _ = noteEndingAt(take, pitch, ppqA)
local ni, nsppq, neppq = noteStartingAt(take, pitch, ppqB)
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)
@@ -352,6 +352,106 @@ function M.toggleCell(take, pitch)
return result, detail
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