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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
local G = dofile("D:\\code\\midigrid\\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")
|
|
|
|
|
|
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-14 18:13:45 +02:00
|
|
|
print(fails == 0 and "ALL PASS" or (fails .. " FAILURES"))
|