diff --git a/MidiGrid_Join.lua b/MidiGrid_Join.lua new file mode 100644 index 0000000..7197a19 --- /dev/null +++ b/MidiGrid_Join.lua @@ -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 diff --git a/MidiGrid_Lengthen.lua b/MidiGrid_Lengthen.lua new file mode 100644 index 0000000..34e463f --- /dev/null +++ b/MidiGrid_Lengthen.lua @@ -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 }) diff --git a/MidiGrid_Shorten.lua b/MidiGrid_Shorten.lua new file mode 100644 index 0000000..873c6df --- /dev/null +++ b/MidiGrid_Shorten.lua @@ -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 }) diff --git a/README.md b/README.md index ef2d401..d2502ed 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/install.ps1 b/install.ps1 index 85c65ba..e35da67 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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" diff --git a/midigrid_lib.lua b/midigrid_lib.lua index 03a9831..a585dd8 100644 --- a/midigrid_lib.lua +++ b/midigrid_lib.lua @@ -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