Arrowing cell by cell is right inside a dense bar and tedious across an empty one. Three pairs of keys now jump straight to a note that is really there, one pair for each axis: Alt+Up/Down the column -- next pitch sounding in this cell Alt+Left/Right the row -- next note at the cursor's pitch Ctrl+Alt+Left/Right anywhere -- next note in the take, any pitch plus Ctrl+Alt+Up/Down for whole-octave movement, which needs no scale awareness because an octave preserves the pitch class. All the jumps ignore muted notes, which are not audible content, and the row and any-note jumps anchor on note starts, so a note held across eight cells is one stop rather than eight. Landing in a chord takes whichever note is nearest the pitch you came from rather than always the bottom one. They sit on Ctrl+Alt rather than plain Ctrl because REAPER already uses Ctrl+arrows to move between items. The logic lives in the lib as usual, with four run* drivers following the runShift pattern, so the eight new action scripts are seven lines each. test_lib.lua previously dofile'd an absolute D:\ path and so could not run outside one machine; it now resolves the lib relative to itself. Stubbing MIDI_GetNote and the PPQ conversions reaches further than the old "pure logic only" boundary, so the new tests cover the search functions and the drivers' speech and cursor movement too.
308 lines
14 KiB
Lua
308 lines
14 KiB
Lua
-- 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")
|
|
|
|
print(fails == 0 and "ALL PASS" or (fails .. " FAILURES"))
|