Files

343 lines
15 KiB
Lua
Raw Permalink Normal View History

-- 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,
}
-- 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")
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")
-- 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")
-- 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")
-- 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")
--------------------------------------------------- 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")
print(fails == 0 and "ALL PASS" or (fails .. " FAILURES"))