From c55ab24ae45fbedf4c7426a2aa8920a23f642029 Mon Sep 17 00:00:00 2001 From: Talon Date: Mon, 24 Aug 2026 01:15:57 +0200 Subject: [PATCH] Add octave jumps and jumping to notes that exist 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. --- CLAUDE.md | 5 +- MidiGrid_ColumnDown.lua | 7 ++ MidiGrid_ColumnUp.lua | 7 ++ MidiGrid_NoteNext.lua | 7 ++ MidiGrid_NotePrev.lua | 7 ++ MidiGrid_OctaveDown.lua | 7 ++ MidiGrid_OctaveUp.lua | 7 ++ MidiGrid_RowNext.lua | 7 ++ MidiGrid_RowPrev.lua | 7 ++ README.md | 59 ++++++++++++++ install.ps1 | 16 ++++ install.sh | 16 ++++ midigrid_lib.lua | 169 ++++++++++++++++++++++++++++++++++++++ test_lib.lua | 177 +++++++++++++++++++++++++++++++++++++++- 14 files changed, 495 insertions(+), 3 deletions(-) create mode 100644 MidiGrid_ColumnDown.lua create mode 100644 MidiGrid_ColumnUp.lua create mode 100644 MidiGrid_NoteNext.lua create mode 100644 MidiGrid_NotePrev.lua create mode 100644 MidiGrid_OctaveDown.lua create mode 100644 MidiGrid_OctaveUp.lua create mode 100644 MidiGrid_RowNext.lua create mode 100644 MidiGrid_RowPrev.lua diff --git a/CLAUDE.md b/CLAUDE.md index b28da7b..99b6b9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,8 +25,9 @@ is a single script of `eq(got, want, label)` assertions printing `ALL PASS` or a failure count; to run one case, comment out the others. It stubs a global `reaper` table, so it only covers the pure logic (scale stepping, note naming, grid labels, chord construction). Anything touching MIDI data or the REAPER API -can only be exercised inside REAPER. Note it `dofile`s an **absolute** path to -`midigrid_lib.lua`. +can only be exercised inside REAPER, though the note-jump tests show how far +a stubbed `MIDI_GetNote` will get you. It resolves `midigrid_lib.lua` +relative to its own path, so it runs from any checkout. ## Architecture diff --git a/MidiGrid_ColumnDown.lua b/MidiGrid_ColumnDown.lua new file mode 100644 index 0000000..17af4f3 --- /dev/null +++ b/MidiGrid_ColumnDown.lua @@ -0,0 +1,7 @@ +-- MIDI Grid: next note below in this cell (pass-through when grid mode is off) +local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])") +local G = dofile(dir .. "midigrid_lib.lua") + +if not G.isActive() then G.passThrough("_OSARA_LOWERNOTEINCHORD") return end + +G.runColumnJump(-1) diff --git a/MidiGrid_ColumnUp.lua b/MidiGrid_ColumnUp.lua new file mode 100644 index 0000000..fff8ef8 --- /dev/null +++ b/MidiGrid_ColumnUp.lua @@ -0,0 +1,7 @@ +-- MIDI Grid: next note above in this cell (pass-through when grid mode is off) +local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])") +local G = dofile(dir .. "midigrid_lib.lua") + +if not G.isActive() then G.passThrough("_OSARA_HIGHERNOTEINCHORD") return end + +G.runColumnJump(1) diff --git a/MidiGrid_NoteNext.lua b/MidiGrid_NoteNext.lua new file mode 100644 index 0000000..0dee2db --- /dev/null +++ b/MidiGrid_NoteNext.lua @@ -0,0 +1,7 @@ +-- MIDI Grid: next note anywhere (pass-through when grid mode is off) +local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])") +local G = dofile(dir .. "midigrid_lib.lua") + +if not G.isActive() then G.passThrough("_OSARA_NEXTCHORD") return end + +G.runNoteJump(1) diff --git a/MidiGrid_NotePrev.lua b/MidiGrid_NotePrev.lua new file mode 100644 index 0000000..be58c95 --- /dev/null +++ b/MidiGrid_NotePrev.lua @@ -0,0 +1,7 @@ +-- MIDI Grid: previous note anywhere (pass-through when grid mode is off) +local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])") +local G = dofile(dir .. "midigrid_lib.lua") + +if not G.isActive() then G.passThrough("_OSARA_PREVCHORD") return end + +G.runNoteJump(-1) diff --git a/MidiGrid_OctaveDown.lua b/MidiGrid_OctaveDown.lua new file mode 100644 index 0000000..9777fe3 --- /dev/null +++ b/MidiGrid_OctaveDown.lua @@ -0,0 +1,7 @@ +-- MIDI Grid: pitch down one octave (pass-through when grid mode is off) +local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])") +local G = dofile(dir .. "midigrid_lib.lua") + +if not G.isActive() then G.passThrough("_OSARA_LOWERNOTEINCHORD") return end + +G.runOctave(-1) diff --git a/MidiGrid_OctaveUp.lua b/MidiGrid_OctaveUp.lua new file mode 100644 index 0000000..9890fd9 --- /dev/null +++ b/MidiGrid_OctaveUp.lua @@ -0,0 +1,7 @@ +-- MIDI Grid: pitch up one octave (pass-through when grid mode is off) +local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])") +local G = dofile(dir .. "midigrid_lib.lua") + +if not G.isActive() then G.passThrough("_OSARA_HIGHERNOTEINCHORD") return end + +G.runOctave(1) diff --git a/MidiGrid_RowNext.lua b/MidiGrid_RowNext.lua new file mode 100644 index 0000000..c9c4e50 --- /dev/null +++ b/MidiGrid_RowNext.lua @@ -0,0 +1,7 @@ +-- MIDI Grid: next note at this pitch (pass-through when grid mode is off) +local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])") +local G = dofile(dir .. "midigrid_lib.lua") + +if not G.isActive() then G.passThrough("_OSARA_NEXTCHORD") return end + +G.runRowJump(1) diff --git a/MidiGrid_RowPrev.lua b/MidiGrid_RowPrev.lua new file mode 100644 index 0000000..4b97589 --- /dev/null +++ b/MidiGrid_RowPrev.lua @@ -0,0 +1,7 @@ +-- MIDI Grid: previous note at this pitch (pass-through when grid mode is off) +local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])") +local G = dofile(dir .. "midigrid_lib.lua") + +if not G.isActive() then G.passThrough("_OSARA_PREVCHORD") return end + +G.runRowJump(-1) diff --git a/README.md b/README.md index 87995d9..4fce5e1 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ keyboard-and-speech use with [OSARA](https://osara.reaperaccessibility.com/). Left and right walk the timeline one grid cell at a time. Up and down walk the pitch axis **by scale degree**. Enter toggles a note in the current cell. +With a modifier the same arrows jump an octave, or straight to the next note +that actually exists — up the cell, along the pitch, or anywhere in the take. Everything you land on is spoken and sounded, so you can hear what is already there as you move. @@ -145,6 +147,10 @@ above. Binding by hand in the action list always works. |---|---|---| | `Left` / `Right` | Cursor back / forward one grid cell | OSARA previous / next chord | | `Up` / `Down` | Pitch up / down one scale degree | OSARA higher / lower note in chord | +| `Ctrl+Alt+Up` / `Ctrl+Alt+Down` | Pitch up / down one **octave** | OSARA higher / lower note in chord | +| `Alt+Up` / `Alt+Down` | Jump to the next note above / below **in this cell** | OSARA higher / lower note in chord | +| `Alt+Left` / `Alt+Right` | Jump to the previous / next note **at this pitch** | OSARA previous / next chord | +| `Ctrl+Alt+Left` / `Ctrl+Alt+Right` | Jump to the previous / next note **anywhere** | OSARA previous / next chord | | `Enter` | Toggle note at cursor cell | Edit: Event properties | | `Ctrl+Shift+Left` / `Ctrl+Shift+Right` | Shift **all** notes one cell, refusing if any would leave the item | — | | `Ctrl+Alt+Shift+Left` / `Ctrl+Alt+Shift+Right` | Same, but shift anyway | — | @@ -186,6 +192,59 @@ the editor to toggle back off. pitch: `"E5, on"`. - **Enter** — `"E5 on, added"` or `"E5 off, removed"`. +## Octaves + +`Ctrl+Alt+Up` and `Ctrl+Alt+Down` move a whole octave, keeping the pitch class. + +This needs no scale awareness, because an octave is transpositionally neutral: +a scale tone stays a scale tone, and a note you deliberately put outside the +key stays outside it. It is also the quick way to cover distance — seven +presses of `Up` to cross an octave in a major scale, one press of +`Ctrl+Alt+Up`. + +At either end of the MIDI range it says *"top"* or *"bottom"* and stays put, +rather than clamping to a partial jump. + +## Jumping to notes that exist + +Arrowing cell by cell is right inside a dense bar and tedious across an empty +one. Three pairs of keys jump straight to a note that is really there, one +pair for each axis you might be looking along. + +- **`Alt+Up` / `Alt+Down` — the column.** The next note above or below *in the + cell you are already on*; time does not move. This is how you walk a chord + you have stacked up — it stops only where notes are, so you never count + scale degrees through the gaps. Speaks the note, as `Up` and `Down` do: + *"E5, on"*. +- **`Alt+Left` / `Alt+Right` — the row.** The next note *at the pitch you are + on*, backwards or forwards in time; the pitch cursor stays put. Use it to + follow one line — a bass note, a hi-hat — through a bar with plenty else + going on. Speaks the cell it landed in, as `Left` and `Right` do: + *"2.1.00, C5"*. +- **`Ctrl+Alt+Left` / `Ctrl+Alt+Right` — anywhere.** The next note in the take + whatever its pitch; both cursors move. This is the one for crossing empty + space. When it lands in a chord it takes whichever note is nearest the pitch + you came from, rather than always the bottom one, and names it when the cell + holds more than one: *"1.1.00, C5, E5, G5, cursor G5"*. + +Two rules they all share. + +**Muted notes are invisible to them.** A muted note makes no sound, so stopping +on one and announcing it would be a lie. + +**A sustained note is one stop, not one per cell it covers.** The row and +any-note jumps anchor on note *starts*, so a note held across eight cells is a +single destination. Jumping backwards from inside a held note therefore takes +you to its beginning, which is usually where you wanted to go. + +When there is nothing that way they say so and stay where they are — *"No note +above in this cell"*, *"No later E5"*, *"No later notes"* — so a key that does +not move you still tells you why. + +These four and the octave keys sit on `Ctrl+Alt` rather than plain `Ctrl` +because REAPER already uses `Ctrl`+arrows to move between items, which is +worth keeping. + ## Hold mode With hold **off**, toggling adjacent cells at the same pitch gives separate diff --git a/install.ps1 b/install.ps1 index 4c4f549..075d624 100644 --- a/install.ps1 +++ b/install.ps1 @@ -45,6 +45,14 @@ $actions = @( @('RSmidigrid_right', 32060, 'MIDI Grid: Cursor right one cell', 'MidiGrid_Right.lua'), @('RSmidigrid_up', 32060, 'MIDI Grid: Pitch up one scale degree', 'MidiGrid_Up.lua'), @('RSmidigrid_down', 32060, 'MIDI Grid: Pitch down one scale degree', 'MidiGrid_Down.lua'), + @('RSmidigrid_octup', 32060, 'MIDI Grid: Pitch up one octave', 'MidiGrid_OctaveUp.lua'), + @('RSmidigrid_octdn', 32060, 'MIDI Grid: Pitch down one octave', 'MidiGrid_OctaveDown.lua'), + @('RSmidigrid_colup', 32060, 'MIDI Grid: Next note above in this cell', 'MidiGrid_ColumnUp.lua'), + @('RSmidigrid_coldn', 32060, 'MIDI Grid: Next note below in this cell', 'MidiGrid_ColumnDown.lua'), + @('RSmidigrid_rownext',32060, 'MIDI Grid: Next note at this pitch', 'MidiGrid_RowNext.lua'), + @('RSmidigrid_rowprev',32060, 'MIDI Grid: Previous note at this pitch', 'MidiGrid_RowPrev.lua'), + @('RSmidigrid_notenext',32060,'MIDI Grid: Next note anywhere', 'MidiGrid_NoteNext.lua'), + @('RSmidigrid_noteprev',32060,'MIDI Grid: Previous note anywhere', 'MidiGrid_NotePrev.lua'), @('RSmidigrid_cell', 32060, 'MIDI Grid: Toggle note at cursor cell', 'MidiGrid_ToggleCell.lua'), @('RSmidigrid_hold', 32060, 'MIDI Grid: Toggle hold mode', 'MidiGrid_ToggleHold.lua'), @('RSmidigrid_audit', 32060, 'MIDI Grid: Repeat current cell', 'MidiGrid_AuditionCell.lua'), @@ -75,6 +83,14 @@ $keys = @( @(1, 32807, 32060, 'RSmidigrid_right'), @(1, 32806, 32060, 'RSmidigrid_up'), @(1, 32808, 32060, 'RSmidigrid_down'), + @(25, 32806, 32060, 'RSmidigrid_octup'), + @(25, 32808, 32060, 'RSmidigrid_octdn'), + @(17, 32806, 32060, 'RSmidigrid_colup'), + @(17, 32808, 32060, 'RSmidigrid_coldn'), + @(17, 32807, 32060, 'RSmidigrid_rownext'), + @(17, 32805, 32060, 'RSmidigrid_rowprev'), + @(25, 32807, 32060, 'RSmidigrid_notenext'), + @(25, 32805, 32060, 'RSmidigrid_noteprev'), @(1, 13, 32060, 'RSmidigrid_cell'), @(17, 72, 32060, 'RSmidigrid_hold'), @(17, 65, 32060, 'RSmidigrid_audit'), diff --git a/install.sh b/install.sh index 1af76c7..85db136 100755 --- a/install.sh +++ b/install.sh @@ -71,6 +71,14 @@ RSmidigrid_left|32060|MIDI Grid: Cursor left one cell|MidiGrid_Left.lua RSmidigrid_right|32060|MIDI Grid: Cursor right one cell|MidiGrid_Right.lua RSmidigrid_up|32060|MIDI Grid: Pitch up one scale degree|MidiGrid_Up.lua RSmidigrid_down|32060|MIDI Grid: Pitch down one scale degree|MidiGrid_Down.lua +RSmidigrid_octup|32060|MIDI Grid: Pitch up one octave|MidiGrid_OctaveUp.lua +RSmidigrid_octdn|32060|MIDI Grid: Pitch down one octave|MidiGrid_OctaveDown.lua +RSmidigrid_colup|32060|MIDI Grid: Next note above in this cell|MidiGrid_ColumnUp.lua +RSmidigrid_coldn|32060|MIDI Grid: Next note below in this cell|MidiGrid_ColumnDown.lua +RSmidigrid_rownext|32060|MIDI Grid: Next note at this pitch|MidiGrid_RowNext.lua +RSmidigrid_rowprev|32060|MIDI Grid: Previous note at this pitch|MidiGrid_RowPrev.lua +RSmidigrid_notenext|32060|MIDI Grid: Next note anywhere|MidiGrid_NoteNext.lua +RSmidigrid_noteprev|32060|MIDI Grid: Previous note anywhere|MidiGrid_NotePrev.lua RSmidigrid_cell|32060|MIDI Grid: Toggle note at cursor cell|MidiGrid_ToggleCell.lua RSmidigrid_hold|32060|MIDI Grid: Toggle hold mode|MidiGrid_ToggleHold.lua RSmidigrid_audit|32060|MIDI Grid: Repeat current cell|MidiGrid_AuditionCell.lua @@ -101,6 +109,14 @@ keys=' 1|32807|32060|RSmidigrid_right 1|32806|32060|RSmidigrid_up 1|32808|32060|RSmidigrid_down +25|32806|32060|RSmidigrid_octup +25|32808|32060|RSmidigrid_octdn +17|32806|32060|RSmidigrid_colup +17|32808|32060|RSmidigrid_coldn +17|32807|32060|RSmidigrid_rownext +17|32805|32060|RSmidigrid_rowprev +25|32807|32060|RSmidigrid_notenext +25|32805|32060|RSmidigrid_noteprev 1|13|32060|RSmidigrid_cell 17|72|32060|RSmidigrid_hold 17|65|32060|RSmidigrid_audit diff --git a/midigrid_lib.lua b/midigrid_lib.lua index db19432..449afca 100644 --- a/midigrid_lib.lua +++ b/midigrid_lib.lua @@ -285,6 +285,175 @@ function M.pitchesInCell(take, ppqA, ppqB) return uniq end +------------------------------------------------------------ octave jumps + +-- Jump a whole octave, keeping the pitch class. Because the octave is +-- transpositionally neutral, a scale tone stays a scale tone, so this needs +-- no scale awareness at all -- unlike scaleStep it just adds 12. +-- Returns nil at the ends of the MIDI range rather than clamping, so the +-- caller can say "top" instead of pretending it moved. +function M.octaveStep(pitch, dir) + local p = pitch + 12 * dir + if p < 0 or p > 127 then return nil end + return p +end + +------------------------------------------------------ jumping to real notes + +--[[ + Arrowing cell by cell is right inside a dense bar and tedious across an + empty one. These jump straight to a note that is actually there, along the + three axes you can be looking down: + + column -- the pitches sounding in the cell you are on, up or down + row -- the same pitch, forwards or backwards in time + any -- the next note in the take whatever its pitch + + Two rules they all share. They ignore muted notes, because a muted note is + not audible content and stopping on one would be a lie. And, apart from the + column jump, they anchor on note *starts*, so a note sustained over eight + cells is one stop rather than eight. +]] + +-- Nearest pitch sounding in the cell strictly above (dir 1) or below (dir -1) +-- `pitch`. The cell's pitches arrive sorted, so the first match in the right +-- direction is the nearest one. +function M.pitchInCellToward(take, ppqA, ppqB, pitch, dir) + local ps = M.pitchesInCell(take, ppqA, ppqB) + if dir > 0 then + for i = 1, #ps do if ps[i] > pitch then return ps[i] end end + else + for i = #ps, 1, -1 do if ps[i] < pitch then return ps[i] end end + end + return nil +end + +-- Nearest note start beyond the current cell in direction dir. +-- opts.pitch -- restrict to this one pitch (row navigation) +-- opts.near -- when several notes share the winning position, prefer the +-- one closest to this pitch, so jumping into a chord lands +-- where you were rather than at its bottom +-- Returns startPPQ, pitch, or nil when there is nothing that way. +function M.nextNoteStart(take, ppqA, ppqB, dir, opts) + opts = opts or {} + local bestPos, bestPitch + local _, notecnt = reaper.MIDI_CountEvts(take) + + for i = 0, notecnt - 1 do + local ok, _, muted, sppq, _, _, p = reaper.MIDI_GetNote(take, i) + local wanted = ok and not muted and (not opts.pitch or p == opts.pitch) + local beyond = wanted and + (dir > 0 and sppq > ppqB - SLACK or dir < 0 and sppq < ppqA - SLACK) + + if beyond then + local better + if not bestPos then + better = true + elseif math.abs(sppq - bestPos) <= SLACK then + -- Same position: a chord. Break the tie towards opts.near. + better = opts.near ~= nil and + math.abs(p - opts.near) < math.abs(bestPitch - opts.near) + elseif dir > 0 then + better = sppq < bestPos + else + better = sppq > bestPos + end + if better then bestPos, bestPitch = sppq, p end + end + end + + return bestPos, bestPitch +end + +-- Which grid cell a PPQ position falls in. Converted through quarter notes, +-- like everything else here, so it survives tempo changes. +function M.cellIndexAtPPQ(take, ppq) + local g = M.gridQN(take) + local qn = reaper.MIDI_GetProjQNFromPPQPos(take, ppq) + return math.floor(qn / g + 1e-9) +end + +-- Speak and sound the cell the cursor has landed in, naming the pitch cursor +-- as well when the cell holds more than one note and the answer is therefore +-- not obvious. +local function announceCell(take, pitch) + local ppqA, ppqB = M.cellPPQ(take) + local ps = M.pitchesInCell(take, ppqA, ppqB) + local msg = M.cellPosText(take) .. ", " .. M.cellContentText(take) + if pitch and #ps > 1 then msg = msg .. ", cursor " .. M.noteName(pitch) end + M.say(msg) + M.preview(ps) +end + +-- Shared driver for the two octave actions. +function M.runOctave(dir) + local hwnd, take, err = M.editor() + if not take then M.say(err) return end + + local p = M.octaveStep(M.getPitch(hwnd), dir) + if not p then M.say(dir > 0 and "top" or "bottom") return end + M.setPitch(hwnd, p) + + local ppqA, ppqB = M.cellPPQ(take) + local on = M.noteInCell(take, p, ppqA, ppqB) ~= nil + M.say(M.noteName(p) .. (on and ", on" or "")) + M.preview({ p }) +end + +-- Shared driver for the two column actions: next note up or down within the +-- cell the cursor is already on. Time does not move. +function M.runColumnJump(dir) + local hwnd, take, err = M.editor() + if not take then M.say(err) return end + + local ppqA, ppqB = M.cellPPQ(take) + local p = M.pitchInCellToward(take, ppqA, ppqB, M.getPitch(hwnd), dir) + if not p then + M.say(dir > 0 and "No note above in this cell" or "No note below in this cell") + return + end + + M.setPitch(hwnd, p) + M.say(M.noteName(p) .. ", on") + M.preview({ p }) +end + +-- Shared driver for the two row actions: next note at the cursor's own pitch, +-- forwards or backwards. The pitch cursor does not move. +function M.runRowJump(dir) + local hwnd, take, err = M.editor() + if not take then M.say(err) return end + + local pitch = M.getPitch(hwnd) + local ppqA, ppqB = M.cellPPQ(take) + local pos = M.nextNoteStart(take, ppqA, ppqB, dir, { pitch = pitch }) + if not pos then + M.say(("No %s %s"):format(dir > 0 and "later" or "earlier", M.noteName(pitch))) + return + end + + M.gotoCell(take, M.cellIndexAtPPQ(take, pos)) + announceCell(take, pitch) +end + +-- Shared driver for the two any-note actions: the next note anywhere in the +-- take, moving both cursors. +function M.runNoteJump(dir) + local hwnd, take, err = M.editor() + if not take then M.say(err) return end + + local ppqA, ppqB = M.cellPPQ(take) + local pos, p = M.nextNoteStart(take, ppqA, ppqB, dir, { near = M.getPitch(hwnd) }) + if not pos then + M.say(dir > 0 and "No later notes" or "No earlier notes") + return + end + + M.setPitch(hwnd, p) + M.gotoCell(take, M.cellIndexAtPPQ(take, pos)) + announceCell(take, p) +end + --------------------------------------------------------------------- edits -- Toggle a note on/off at (current cell, pitch). diff --git a/test_lib.lua b/test_lib.lua index b3eba80..37edd20 100644 --- a/test_lib.lua +++ b/test_lib.lua @@ -10,7 +10,10 @@ reaper = { TimeMap_GetTimeSigAtTime = function() return 4, 4 end, } -local G = dofile("D:\\code\\midigrid\\midigrid_lib.lua") +-- 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) @@ -129,4 +132,176 @@ 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"))