diff --git a/MidiGrid_ShiftLeft.lua b/MidiGrid_ShiftLeft.lua new file mode 100644 index 0000000..43b0270 --- /dev/null +++ b/MidiGrid_ShiftLeft.lua @@ -0,0 +1,5 @@ +-- MIDI Grid: shift all notes one cell left, refusing if any would be lost +local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])") +local G = dofile(dir .. "midigrid_lib.lua") +if not G.isActive() then return end +G.runShift(-1, false) diff --git a/MidiGrid_ShiftLeftForce.lua b/MidiGrid_ShiftLeftForce.lua new file mode 100644 index 0000000..8f77178 --- /dev/null +++ b/MidiGrid_ShiftLeftForce.lua @@ -0,0 +1,5 @@ +-- MIDI Grid: shift all notes one cell left even if some leave the item +local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])") +local G = dofile(dir .. "midigrid_lib.lua") +if not G.isActive() then return end +G.runShift(-1, true) diff --git a/MidiGrid_ShiftRight.lua b/MidiGrid_ShiftRight.lua new file mode 100644 index 0000000..721976f --- /dev/null +++ b/MidiGrid_ShiftRight.lua @@ -0,0 +1,5 @@ +-- MIDI Grid: shift all notes one cell right, refusing if any would be lost +local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])") +local G = dofile(dir .. "midigrid_lib.lua") +if not G.isActive() then return end +G.runShift(1, false) diff --git a/MidiGrid_ShiftRightForce.lua b/MidiGrid_ShiftRightForce.lua new file mode 100644 index 0000000..1b767be --- /dev/null +++ b/MidiGrid_ShiftRightForce.lua @@ -0,0 +1,5 @@ +-- MIDI Grid: shift all notes one cell right even if some leave the item +local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])") +local G = dofile(dir .. "midigrid_lib.lua") +if not G.isActive() then return end +G.runShift(1, true) diff --git a/README.md b/README.md index d2502ed..dbef4a7 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 | +| `Ctrl+Shift+Left` / `Ctrl+Shift+Right` | Shift **all** notes one cell, refusing if any would leave the item | — | +| `Ctrl+Alt+Shift+Left` / `Ctrl+Alt+Shift+Right` | Same, but shift anyway | — | | `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 | — | @@ -147,6 +149,27 @@ 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. +## Shifting notes in time + +If a whole clip landed a couple of cells off the beat, `Ctrl+Shift+Left` and +`Ctrl+Shift+Right` move **every note in the take** by one grid cell. + +Because the entire take moves together, notes can never collide with each +other. What is at stake is the **item boundary**: notes pushed past either end +stop sounding. They are *not* deleted — shifting back the other way brings +them straight back — but a clip that has quietly lost its first bar is not +something you want to discover two hours later. + +So the plain keys **refuse** the shift if any note would end up outside, and +say how many: *"Not shifted left, 3 notes would fall outside the item. Add Alt +to force."* + +Adding **Alt** (`Ctrl+Alt+Shift+Left` / `Right`) does it anyway, reporting the +consequence: *"Shifted left, 3 notes now outside the item and silent."* + +Positions are converted through quarter-notes rather than offset in raw ticks, +so a shift stays musically correct across tempo changes. + ## Chords `Alt+3` stamps a **diatonic triad** on the cursor pitch; `Alt+7` a seventh diff --git a/install.ps1 b/install.ps1 index e35da67..8c32e0a 100644 --- a/install.ps1 +++ b/install.ps1 @@ -55,7 +55,11 @@ $actions = @( @('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') + @('RSmidigrid_join', 32060, 'MIDI Grid: Join run of repeated notes', 'MidiGrid_Join.lua'), + @('RSmidigrid_shl', 32060, 'MIDI Grid: Shift all notes one cell left', 'MidiGrid_ShiftLeft.lua'), + @('RSmidigrid_shr', 32060, 'MIDI Grid: Shift all notes one cell right', 'MidiGrid_ShiftRight.lua'), + @('RSmidigrid_shlf', 32060, 'MIDI Grid: Shift all notes left (force)', 'MidiGrid_ShiftLeftForce.lua'), + @('RSmidigrid_shrf', 32060, 'MIDI Grid: Shift all notes right (force)', 'MidiGrid_ShiftRightForce.lua') ) # flags, keycode, section, id @@ -78,7 +82,11 @@ $keys = @( @(21, 32808, 32060, 'RSmidigrid_veldn'), @(1, 76, 32060, 'RSmidigrid_longer'), @(5, 76, 32060, 'RSmidigrid_shorter'), - @(17, 74, 32060, 'RSmidigrid_join') + @(17, 74, 32060, 'RSmidigrid_join'), + @(13, 32805, 32060, 'RSmidigrid_shl'), + @(13, 32807, 32060, 'RSmidigrid_shr'), + @(29, 32805, 32060, 'RSmidigrid_shlf'), + @(29, 32807, 32060, 'RSmidigrid_shrf') ) $backup = "$kb.midigrid-backup" diff --git a/midigrid_lib.lua b/midigrid_lib.lua index a585dd8..a331fb3 100644 --- a/midigrid_lib.lua +++ b/midigrid_lib.lua @@ -352,6 +352,83 @@ function M.toggleCell(take, pitch) return result, detail 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 --[[