2026-08-14 18:13:45 +02:00
|
|
|
-- Stub just enough of the reaper API to exercise the pure logic in the lib.
|
|
|
|
|
local ext = { root = "0", scale = "1" }
|
|
|
|
|
reaper = {
|
|
|
|
|
GetExtState = function(_, k) return ext[k] or "" end,
|
|
|
|
|
SetExtState = function(_, k, v) ext[k] = v end,
|
|
|
|
|
APIExists = function() return false end,
|
|
|
|
|
ShowConsoleMsg = function() end,
|
|
|
|
|
SNM_GetIntConfigVar = function(_, d) return 1 end,
|
|
|
|
|
GetCursorPosition = function() return 0 end,
|
|
|
|
|
TimeMap_GetTimeSigAtTime = function() return 4, 4 end,
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-24 01:15:57 +02:00
|
|
|
-- Resolve the lib next to this script, so the tests run from any checkout
|
|
|
|
|
-- and on any platform.
|
|
|
|
|
local here = (arg and arg[0] or "test_lib.lua"):match("(.*[\\/])") or "./"
|
|
|
|
|
local G = dofile(here .. "midigrid_lib.lua")
|
2026-08-14 18:13:45 +02:00
|
|
|
|
|
|
|
|
local fails = 0
|
|
|
|
|
local function eq(got, want, label)
|
|
|
|
|
if got ~= want then
|
|
|
|
|
print(("FAIL %s: got %s want %s"):format(label, tostring(got), tostring(want)))
|
|
|
|
|
fails = fails + 1
|
|
|
|
|
end
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Note naming: with midioctoffs=1, note 60 reads as C5 (Reaper's default).
|
|
|
|
|
eq(G.noteName(60), "C5", "noteName 60")
|
|
|
|
|
eq(G.noteName(61), "C#5", "noteName 61")
|
|
|
|
|
eq(G.noteName(72), "C6", "noteName 72")
|
|
|
|
|
eq(G.noteName(48), "C4", "noteName 48")
|
|
|
|
|
|
|
|
|
|
-- C major: stepping up from C5 walks the scale, not semitones.
|
|
|
|
|
ext.root, ext.scale = "0", "1"
|
|
|
|
|
local p, seq = 60, {}
|
|
|
|
|
for _ = 1, 7 do p = G.scaleStep(p, 1); seq[#seq + 1] = p end
|
|
|
|
|
eq(table.concat(seq, ","), "62,64,65,67,69,71,72", "C major ascending")
|
|
|
|
|
|
|
|
|
|
p, seq = 60, {}
|
|
|
|
|
for _ = 1, 3 do p = G.scaleStep(p, -1); seq[#seq + 1] = p end
|
|
|
|
|
eq(table.concat(seq, ","), "59,57,55", "C major descending")
|
|
|
|
|
|
|
|
|
|
-- An off-scale note must not trap the cursor: C#5 in C major steps to D5.
|
|
|
|
|
eq(G.scaleStep(61, 1), 62, "off-scale up")
|
|
|
|
|
eq(G.scaleStep(61, -1), 60, "off-scale down")
|
|
|
|
|
|
|
|
|
|
-- Pentatonic skips more than a tone.
|
|
|
|
|
ext.scale = "10" -- major pentatonic
|
|
|
|
|
eq(G.scaleStep(60, 1), 62, "pentatonic C->D")
|
|
|
|
|
eq(G.scaleStep(64, 1), 67, "pentatonic E->G")
|
|
|
|
|
|
|
|
|
|
-- Chromatic behaves like plain semitones.
|
|
|
|
|
ext.scale = "14"
|
|
|
|
|
eq(G.scaleStep(60, 1), 61, "chromatic up")
|
|
|
|
|
|
|
|
|
|
-- Root transposition: A natural minor.
|
|
|
|
|
ext.root, ext.scale = "9", "2"
|
|
|
|
|
eq(G.inScale(69), true, "A in A minor")
|
|
|
|
|
eq(G.inScale(70), false, "A# not in A minor")
|
|
|
|
|
eq(G.scaleName(), "A natural minor", "scale name")
|
|
|
|
|
|
|
|
|
|
-- Range clamping at the extremes of the MIDI range.
|
|
|
|
|
ext.root, ext.scale = "0", "1"
|
|
|
|
|
eq(G.scaleStep(127, 1), nil, "top of range")
|
|
|
|
|
eq(G.scaleStep(0, -1), nil, "bottom of range")
|
|
|
|
|
|
|
|
|
|
-- Grid labels in 4/4: one bar is 4 QN.
|
|
|
|
|
eq(G.gridLabel(4), "1 bar", "grid 1 bar")
|
|
|
|
|
eq(G.gridLabel(8), "2 bars", "grid 2 bars")
|
|
|
|
|
eq(G.gridLabel(1), "1/4", "grid quarter")
|
|
|
|
|
eq(G.gridLabel(0.25), "1/16", "grid sixteenth")
|
|
|
|
|
eq(G.gridLabel(0.5), "1/8", "grid eighth")
|
|
|
|
|
|
2026-08-14 18:18:12 +02:00
|
|
|
-- Diatonic chords: stacking scale thirds must yield the right quality for
|
|
|
|
|
-- each degree, without any chord-quality table.
|
|
|
|
|
ext.root, ext.scale = "0", "1" -- C major
|
|
|
|
|
eq(table.concat(G.chordPitches(60, 3), ","), "60,64,67", "C major triad")
|
|
|
|
|
eq(table.concat(G.chordPitches(62, 3), ","), "62,65,69", "D minor triad")
|
|
|
|
|
eq(table.concat(G.chordPitches(71, 3), ","), "71,74,77", "B diminished triad")
|
|
|
|
|
eq(table.concat(G.chordPitches(60, 4), ","), "60,64,67,71", "C major seventh")
|
|
|
|
|
eq(table.concat(G.chordPitches(67, 4), ","), "67,71,74,77", "G dominant seventh")
|
|
|
|
|
|
|
|
|
|
-- A minor triad from its own root.
|
|
|
|
|
ext.root, ext.scale = "9", "2"
|
|
|
|
|
eq(table.concat(G.chordPitches(69, 3), ","), "69,72,76", "A minor triad")
|
|
|
|
|
|
|
|
|
|
-- Running off the top of the MIDI range yields nil rather than a partial chord.
|
|
|
|
|
ext.root, ext.scale = "0", "1"
|
|
|
|
|
eq(G.chordPitches(127, 3), nil, "chord past top of range")
|
|
|
|
|
|
2026-08-24 00:55:25 +02:00
|
|
|
-- Preview routing: the edited track takes over arm/monitor so preview sounds
|
|
|
|
|
-- through its instrument, any other armed track stands down so it cannot
|
|
|
|
|
-- answer as well, and restore puts every one of them back.
|
|
|
|
|
local tracks = {
|
|
|
|
|
{ guid = "{A}", arm = 0, mon = 0, input = 0 },
|
|
|
|
|
{ guid = "{B}", arm = 0, mon = 0, input = 0 }, -- the track being edited
|
|
|
|
|
{ guid = "{C}", arm = 1, mon = 1, input = 6112 }, -- somebody else's armed track
|
|
|
|
|
}
|
|
|
|
|
local FIELD = { I_RECARM = "arm", I_RECMON = "mon", I_RECINPUT = "input" }
|
|
|
|
|
reaper.CountTracks = function() return #tracks end
|
|
|
|
|
reaper.GetTrack = function(_, i) return tracks[i + 1] end
|
|
|
|
|
reaper.GetTrackGUID = function(t) return t.guid end
|
|
|
|
|
reaper.GetMediaTrackInfo_Value = function(t, k) return t[FIELD[k]] end
|
|
|
|
|
reaper.SetMediaTrackInfo_Value = function(t, k, v) t[FIELD[k]] = v end
|
|
|
|
|
reaper.GetMediaItemTake_Track = function(tk) return tk.track end
|
|
|
|
|
|
|
|
|
|
local take = { track = tracks[2] }
|
|
|
|
|
G.routePreview(take)
|
|
|
|
|
eq(tracks[2].arm, 1, "edited track armed")
|
|
|
|
|
eq(tracks[2].mon, 1, "edited track monitoring")
|
|
|
|
|
eq(tracks[2].input, 6112, "edited track takes MIDI input")
|
|
|
|
|
eq(tracks[3].arm, 0, "other armed track stood down")
|
|
|
|
|
eq(tracks[1].arm, 0, "unarmed bystander untouched")
|
|
|
|
|
|
|
|
|
|
-- Re-pointing at the same track must not re-save the borrowed state as if it
|
|
|
|
|
-- were the original, or restore would hand back grid mode's own settings.
|
|
|
|
|
G.routePreview(take)
|
|
|
|
|
G.restoreRouting()
|
|
|
|
|
eq(tracks[2].arm, 0, "edited track disarmed again")
|
|
|
|
|
eq(tracks[2].mon, 0, "edited track monitoring restored")
|
|
|
|
|
eq(tracks[2].input, 0, "edited track input restored")
|
|
|
|
|
eq(tracks[3].arm, 1, "other track re-armed")
|
|
|
|
|
eq(tracks[3].mon, 1, "other track monitoring restored")
|
|
|
|
|
|
|
|
|
|
-- Restoring twice is harmless, and switching to a take on another track
|
|
|
|
|
-- returns the first one before borrowing the second.
|
|
|
|
|
eq(G.restoreRouting(), false, "restore with nothing borrowed")
|
|
|
|
|
G.routePreview(take)
|
|
|
|
|
G.routePreview({ track = tracks[1] })
|
|
|
|
|
eq(tracks[1].arm, 1, "second track armed")
|
|
|
|
|
eq(tracks[2].arm, 0, "first track handed back on switch")
|
|
|
|
|
G.restoreRouting()
|
|
|
|
|
eq(tracks[1].arm, 0, "second track handed back")
|
|
|
|
|
eq(tracks[3].arm, 1, "bystander still armed at the end")
|
|
|
|
|
|
2026-08-24 01:15:57 +02:00
|
|
|
-- Octave jumps ignore the scale entirely -- an octave is the same pitch
|
|
|
|
|
-- class, so a scale tone stays one -- and stop at the ends of the range.
|
|
|
|
|
ext.root, ext.scale = "0", "1"
|
|
|
|
|
eq(G.octaveStep(60, 1), 72, "octave up")
|
|
|
|
|
eq(G.octaveStep(60, -1), 48, "octave down")
|
|
|
|
|
eq(G.octaveStep(115, 1), 127, "octave up to the very top")
|
|
|
|
|
eq(G.octaveStep(116, 1), nil, "octave up past the top")
|
|
|
|
|
eq(G.octaveStep(11, -1), nil, "octave down past the bottom")
|
|
|
|
|
ext.scale = "10" -- major pentatonic: octaveStep must not consult it
|
|
|
|
|
eq(G.octaveStep(61, 1), 73, "octave up off-scale keeps the pitch class")
|
|
|
|
|
ext.scale = "1"
|
|
|
|
|
|
|
|
|
|
--------------------------------------------------------- jumping to notes
|
|
|
|
|
|
|
|
|
|
-- A fake take with a handful of notes, one of them sustained over two cells
|
|
|
|
|
-- and one muted. Grid is 1 QN, so cell N spans QN N to N+1, and the stubs
|
|
|
|
|
-- below make 1 QN equal 1 second so cursor positions read as QN directly.
|
|
|
|
|
local PPQ = 960
|
|
|
|
|
local notes = {
|
|
|
|
|
{ s = 0, e = 1, p = 60 }, -- cell 0: C5 E5 G5 chord
|
|
|
|
|
{ s = 0, e = 1, p = 64 },
|
|
|
|
|
{ s = 0, e = 1, p = 67 },
|
|
|
|
|
{ s = 4, e = 6, p = 60 }, -- cells 4-5: one sustained C5
|
|
|
|
|
{ s = 6, e = 7, p = 69 }, -- cell 6: A5
|
|
|
|
|
{ s = 9, e = 10, p = 62, muted = true }, -- cell 9: muted, must be invisible
|
|
|
|
|
}
|
|
|
|
|
local cursor, grid, pitchRow = 0, 1, 60
|
|
|
|
|
|
|
|
|
|
reaper.MIDI_GetGrid = function() return grid end
|
|
|
|
|
reaper.GetCursorPosition = function() return cursor end
|
|
|
|
|
reaper.TimeMap2_timeToQN = function(_, t) return t end
|
|
|
|
|
reaper.TimeMap2_QNToTime = function(_, q) return q end
|
|
|
|
|
reaper.SetEditCurPos = function(t) cursor = t end
|
|
|
|
|
reaper.MIDI_GetPPQPosFromProjQN = function(_, q) return q * PPQ end
|
|
|
|
|
reaper.MIDI_GetProjQNFromPPQPos = function(_, q) return q / PPQ end
|
|
|
|
|
reaper.MIDI_CountEvts = function() return true, #notes, 0, 0 end
|
|
|
|
|
reaper.MIDI_GetNote = function(_, i)
|
|
|
|
|
local n = notes[i + 1]
|
|
|
|
|
if not n then return false end
|
|
|
|
|
return true, false, n.muted or false, n.s * PPQ, n.e * PPQ, 0, n.p, 96
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- Enough of a MIDI editor for the action drivers to run against.
|
|
|
|
|
reaper.MIDIEditor_GetActive = function() return "hwnd" end
|
|
|
|
|
reaper.MIDIEditor_GetTake = function() return take end
|
|
|
|
|
reaper.TakeIsMIDI = function() return true end
|
|
|
|
|
reaper.MIDIEditor_GetSetting_int = function() return pitchRow end
|
|
|
|
|
reaper.MIDIEditor_SetSetting_int = function(_, _, v) pitchRow = v end
|
|
|
|
|
reaper.NamedCommandLookup = function() return 0 end
|
|
|
|
|
reaper.Main_OnCommand = function() end
|
|
|
|
|
reaper.format_timestr_pos = function(t)
|
|
|
|
|
return ("%d.%d.00"):format(math.floor(t / 4) + 1, math.floor(t % 4) + 1)
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
-- say() falls through to the console when OSARA is absent; capture that.
|
|
|
|
|
local spoken
|
|
|
|
|
reaper.ShowConsoleMsg = function(m) spoken = m:gsub("\n$", "") end
|
|
|
|
|
|
|
|
|
|
-- Put the cursor in cell `c` with the pitch cursor on `p`, then run `fn`.
|
|
|
|
|
-- Returns what was spoken, where the cursor ended up, and the pitch row.
|
|
|
|
|
local function at(c, p, fn)
|
|
|
|
|
cursor, pitchRow, spoken = c, p, nil
|
|
|
|
|
fn()
|
|
|
|
|
return spoken, cursor, pitchRow
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local function cellPPQ(c) return c * PPQ, (c + 1) * PPQ end
|
|
|
|
|
|
|
|
|
|
-- Column: the pitches actually sounding in this one cell, nearest first.
|
|
|
|
|
local a, b = cellPPQ(0)
|
|
|
|
|
eq(G.pitchInCellToward(take, a, b, 64, 1), 67, "column up from E5")
|
|
|
|
|
eq(G.pitchInCellToward(take, a, b, 64, -1), 60, "column down from E5")
|
|
|
|
|
eq(G.pitchInCellToward(take, a, b, 67, 1), nil, "column up from the top note")
|
|
|
|
|
eq(G.pitchInCellToward(take, a, b, 60, -1), nil, "column down from the bottom note")
|
|
|
|
|
-- From the top of a chord, down must land on the middle note, not skip to
|
|
|
|
|
-- the bottom of it.
|
|
|
|
|
eq(G.pitchInCellToward(take, a, b, 67, -1), 64, "column down takes the nearest, not the lowest")
|
|
|
|
|
eq(G.pitchInCellToward(take, a, b, 60, 1), 64, "column up takes the nearest, not the highest")
|
|
|
|
|
-- The cursor need not be on a note for the column jump to work.
|
|
|
|
|
eq(G.pitchInCellToward(take, a, b, 62, 1), 64, "column up from between notes")
|
|
|
|
|
eq(G.pitchInCellToward(take, a, b, 62, -1), 60, "column down from between notes")
|
|
|
|
|
-- An empty cell has nothing to jump to.
|
|
|
|
|
a, b = cellPPQ(2)
|
|
|
|
|
eq(G.pitchInCellToward(take, a, b, 60, 1), nil, "column up in an empty cell")
|
|
|
|
|
|
|
|
|
|
-- Row: the same pitch, forwards and backwards in time.
|
|
|
|
|
a, b = cellPPQ(0)
|
|
|
|
|
eq(G.nextNoteStart(take, a, b, 1, { pitch = 60 }), 4 * PPQ, "row forward to next C5")
|
|
|
|
|
eq(G.nextNoteStart(take, a, b, 1, { pitch = 64 }), nil, "row forward, no later E5")
|
|
|
|
|
eq(G.nextNoteStart(take, a, b, -1, { pitch = 60 }), nil, "row back from the first cell")
|
|
|
|
|
a, b = cellPPQ(6)
|
|
|
|
|
eq(G.nextNoteStart(take, a, b, -1, { pitch = 60 }), 4 * PPQ, "row back to previous C5")
|
|
|
|
|
-- A note sustained over several cells is one stop, at its start, not one
|
|
|
|
|
-- stop per cell it covers.
|
|
|
|
|
a, b = cellPPQ(5)
|
|
|
|
|
eq(G.nextNoteStart(take, a, b, -1, { pitch = 60 }), 4 * PPQ, "row back inside a held note")
|
|
|
|
|
eq(G.nextNoteStart(take, a, b, 1, { pitch = 60 }), nil, "row forward inside a held note")
|
|
|
|
|
|
|
|
|
|
-- Any pitch: the next note in the take, whatever it is.
|
|
|
|
|
a, b = cellPPQ(0)
|
|
|
|
|
local pos, pit = G.nextNoteStart(take, a, b, 1, { near = 64 })
|
|
|
|
|
eq(pos, 4 * PPQ, "any forward position")
|
|
|
|
|
eq(pit, 60, "any forward pitch")
|
|
|
|
|
a, b = cellPPQ(4)
|
|
|
|
|
pos, pit = G.nextNoteStart(take, a, b, 1, { near = 60 })
|
|
|
|
|
eq(pos, 6 * PPQ, "any forward to A5")
|
|
|
|
|
eq(pit, 69, "any forward pitch A5")
|
|
|
|
|
-- The muted note at cell 9 must not be found: it makes no sound, so
|
|
|
|
|
-- stopping on it would be a lie.
|
|
|
|
|
a, b = cellPPQ(6)
|
|
|
|
|
eq(G.nextNoteStart(take, a, b, 1, {}), nil, "muted notes are skipped")
|
|
|
|
|
|
|
|
|
|
-- Landing in a chord goes to whichever of its notes is nearest the pitch
|
|
|
|
|
-- you were already on, rather than always to the bottom of it.
|
|
|
|
|
a, b = cellPPQ(4)
|
|
|
|
|
eq(select(2, G.nextNoteStart(take, a, b, -1, { near = 59 })), 60, "chord tie-break low")
|
|
|
|
|
eq(select(2, G.nextNoteStart(take, a, b, -1, { near = 65 })), 64, "chord tie-break middle")
|
|
|
|
|
eq(select(2, G.nextNoteStart(take, a, b, -1, { near = 66 })), 67, "chord tie-break high")
|
|
|
|
|
|
|
|
|
|
-- Cell index from a raw position follows the current grid.
|
|
|
|
|
eq(G.cellIndexAtPPQ(take, 4 * PPQ), 4, "cell index at quarter grid")
|
|
|
|
|
grid = 0.25
|
|
|
|
|
eq(G.cellIndexAtPPQ(take, 4 * PPQ), 16, "cell index at sixteenth grid")
|
|
|
|
|
grid = 1
|
|
|
|
|
|
|
|
|
|
------------------------------------------------- what the jump keys say
|
|
|
|
|
|
|
|
|
|
local said, cur, row
|
|
|
|
|
|
|
|
|
|
-- Octave: same speech as a scale step, including the "on" when the cell
|
|
|
|
|
-- already holds that pitch.
|
|
|
|
|
said, _, row = at(0, 60, function() G.runOctave(1) end)
|
|
|
|
|
eq(said, "C6", "octave up speaks the note")
|
|
|
|
|
eq(row, 72, "octave up moved the pitch cursor")
|
|
|
|
|
said, _, row = at(0, 72, function() G.runOctave(-1) end)
|
|
|
|
|
eq(said, "C5, on", "octave down onto an existing note says on")
|
|
|
|
|
eq(row, 60, "octave down moved the pitch cursor")
|
|
|
|
|
said, _, row = at(0, 120, function() G.runOctave(1) end)
|
|
|
|
|
eq(said, "top", "octave up at the top of the range")
|
|
|
|
|
eq(row, 120, "refused octave leaves the pitch cursor alone")
|
|
|
|
|
|
|
|
|
|
-- Column jump: pitch moves, time does not.
|
|
|
|
|
said, cur, row = at(0, 60, function() G.runColumnJump(1) end)
|
|
|
|
|
eq(said, "E5, on", "column up speaks the note it landed on")
|
|
|
|
|
eq(cur, 0, "column up did not move in time")
|
|
|
|
|
eq(row, 64, "column up moved the pitch cursor")
|
|
|
|
|
said, _, row = at(0, 67, function() G.runColumnJump(1) end)
|
|
|
|
|
eq(said, "No note above in this cell", "column up with nothing above")
|
|
|
|
|
eq(row, 67, "failed column jump leaves the pitch cursor alone")
|
|
|
|
|
|
|
|
|
|
-- Row jump: time moves, pitch does not.
|
|
|
|
|
said, cur, row = at(0, 60, function() G.runRowJump(1) end)
|
|
|
|
|
eq(said, "2.1.00, C5", "row forward speaks position and contents")
|
|
|
|
|
eq(cur, 4, "row forward moved the edit cursor")
|
|
|
|
|
eq(row, 60, "row forward kept the pitch cursor")
|
|
|
|
|
said, cur = at(0, 64, function() G.runRowJump(1) end)
|
|
|
|
|
eq(said, "No later E5", "row forward with nothing later at this pitch")
|
|
|
|
|
eq(cur, 0, "failed row jump leaves the edit cursor alone")
|
|
|
|
|
|
|
|
|
|
-- Any-note jump: both cursors move, and the pitch is named when the cell
|
|
|
|
|
-- holds more than one note and so does not name itself.
|
|
|
|
|
said, cur, row = at(4, 60, function() G.runNoteJump(1) end)
|
|
|
|
|
eq(said, "2.3.00, A5", "any forward speaks position and contents")
|
|
|
|
|
eq(cur, 6, "any forward moved the edit cursor")
|
|
|
|
|
eq(row, 69, "any forward moved the pitch cursor")
|
|
|
|
|
said, cur, row = at(4, 66, function() G.runNoteJump(-1) end)
|
|
|
|
|
eq(said, "1.1.00, C5, E5, G5, cursor G5", "any back into a chord names the pitch")
|
|
|
|
|
eq(cur, 0, "any back moved the edit cursor")
|
|
|
|
|
eq(row, 67, "any back landed nearest the pitch it came from")
|
|
|
|
|
said = at(6, 69, function() G.runNoteJump(1) end)
|
|
|
|
|
eq(said, "No later notes", "any forward past the last note")
|
|
|
|
|
|
2026-08-24 01:21:21 +02:00
|
|
|
--------------------------------------------------- entering grid mode
|
|
|
|
|
|
|
|
|
|
-- Opening an item in grid mode keeps the cursor where it already was, rounded
|
|
|
|
|
-- to the nearest cell, instead of dropping back to the item start.
|
|
|
|
|
local itemPos, itemLen = 0, 10
|
|
|
|
|
reaper.GetMediaItemTake_Item = function() return "item" end
|
|
|
|
|
reaper.GetMediaItemInfo_Value = function(_, k)
|
|
|
|
|
return k == "D_POSITION" and itemPos or itemLen
|
|
|
|
|
end
|
|
|
|
|
|
|
|
|
|
local function snap(t) return G.snapCursorToCell(take, t) end
|
|
|
|
|
eq(snap(2.4), 2, "snap rounds back to the nearer cell")
|
|
|
|
|
eq(snap(2.6), 3, "snap rounds forward to the nearer cell")
|
|
|
|
|
eq(snap(3), 3, "snap leaves a cursor already on a cell alone")
|
|
|
|
|
eq(cursor, 3, "snap moves the edit cursor to the cell start")
|
|
|
|
|
cursor = 6.7
|
|
|
|
|
eq(snap(nil), 7, "snap defaults to the edit cursor")
|
|
|
|
|
|
|
|
|
|
-- A cursor outside the item lands on the item's first or last cell.
|
|
|
|
|
itemPos, itemLen = 2, 4 -- the item spans cells 2 to 5
|
|
|
|
|
eq(snap(0), 2, "snap from before the item lands on its first cell")
|
|
|
|
|
eq(snap(99), 5, "snap from past the item lands on its last cell")
|
|
|
|
|
eq(snap(6), 5, "snap at the item end lands on the last cell inside it")
|
|
|
|
|
|
|
|
|
|
-- Cells stay aligned to the timeline, not the item, so an item that starts
|
|
|
|
|
-- off-grid still uses the cell that contains its start as the floor.
|
|
|
|
|
itemPos, itemLen = 2.5, 4
|
|
|
|
|
eq(snap(0), 2, "snap floors to the cell containing an off-grid item start")
|
|
|
|
|
|
|
|
|
|
-- The rounding follows the grid size, not a fixed quarter note.
|
|
|
|
|
grid, itemPos, itemLen = 2, 0, 10
|
|
|
|
|
eq(snap(2.9), 1, "snap rounds to half-note cells on a half-note grid")
|
|
|
|
|
eq(snap(3.1), 2, "snap rounds up to half-note cells on a half-note grid")
|
|
|
|
|
eq(cursor, 4, "half-note cell 2 starts at QN 4")
|
|
|
|
|
|
2026-08-14 18:13:45 +02:00
|
|
|
print(fails == 0 and "ALL PASS" or (fails .. " FAILURES"))
|