Add whole-take time shifting

Ctrl+Shift+Left/Right move every note in the take by one grid cell, for
clips that landed off the beat.

Since the whole take moves together, notes cannot collide with each other;
the hazard is the item boundary. Notes pushed past either end stop sounding
but are not deleted, so the operation is reversible -- shifting back restores
them. The plain keys still refuse when that would happen and report the count,
because a clip that has silently lost its first bar is a bad thing to discover
later. Ctrl+Alt+Shift forces it and names the consequence.

Positions are converted through QN rather than offset in raw ticks, so shifts
stay musically correct across tempo changes.
This commit is contained in:
2026-08-14 18:46:33 +02:00
parent 542f59e0db
commit 51dff21e85
7 changed files with 130 additions and 2 deletions
+77
View File
@@ -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
--[[