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.
This commit is contained in:
@@ -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
|
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,
|
`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
|
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
|
can only be exercised inside REAPER, though the note-jump tests show how far
|
||||||
`midigrid_lib.lua`.
|
a stubbed `MIDI_GetNote` will get you. It resolves `midigrid_lib.lua`
|
||||||
|
relative to its own path, so it runs from any checkout.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
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.
|
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
|
Everything you land on is spoken and sounded, so you can hear what is already
|
||||||
there as you move.
|
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 |
|
| `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 |
|
| `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 |
|
| `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+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 | — |
|
| `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"`.
|
pitch: `"E5, on"`.
|
||||||
- **Enter** — `"E5 on, added"` or `"E5 off, removed"`.
|
- **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
|
## Hold mode
|
||||||
|
|
||||||
With hold **off**, toggling adjacent cells at the same pitch gives separate
|
With hold **off**, toggling adjacent cells at the same pitch gives separate
|
||||||
|
|||||||
+16
@@ -45,6 +45,14 @@ $actions = @(
|
|||||||
@('RSmidigrid_right', 32060, 'MIDI Grid: Cursor right one cell', 'MidiGrid_Right.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_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_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_cell', 32060, 'MIDI Grid: Toggle note at cursor cell', 'MidiGrid_ToggleCell.lua'),
|
||||||
@('RSmidigrid_hold', 32060, 'MIDI Grid: Toggle hold mode', 'MidiGrid_ToggleHold.lua'),
|
@('RSmidigrid_hold', 32060, 'MIDI Grid: Toggle hold mode', 'MidiGrid_ToggleHold.lua'),
|
||||||
@('RSmidigrid_audit', 32060, 'MIDI Grid: Repeat current cell', 'MidiGrid_AuditionCell.lua'),
|
@('RSmidigrid_audit', 32060, 'MIDI Grid: Repeat current cell', 'MidiGrid_AuditionCell.lua'),
|
||||||
@@ -75,6 +83,14 @@ $keys = @(
|
|||||||
@(1, 32807, 32060, 'RSmidigrid_right'),
|
@(1, 32807, 32060, 'RSmidigrid_right'),
|
||||||
@(1, 32806, 32060, 'RSmidigrid_up'),
|
@(1, 32806, 32060, 'RSmidigrid_up'),
|
||||||
@(1, 32808, 32060, 'RSmidigrid_down'),
|
@(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'),
|
@(1, 13, 32060, 'RSmidigrid_cell'),
|
||||||
@(17, 72, 32060, 'RSmidigrid_hold'),
|
@(17, 72, 32060, 'RSmidigrid_hold'),
|
||||||
@(17, 65, 32060, 'RSmidigrid_audit'),
|
@(17, 65, 32060, 'RSmidigrid_audit'),
|
||||||
|
|||||||
+16
@@ -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_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_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_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_cell|32060|MIDI Grid: Toggle note at cursor cell|MidiGrid_ToggleCell.lua
|
||||||
RSmidigrid_hold|32060|MIDI Grid: Toggle hold mode|MidiGrid_ToggleHold.lua
|
RSmidigrid_hold|32060|MIDI Grid: Toggle hold mode|MidiGrid_ToggleHold.lua
|
||||||
RSmidigrid_audit|32060|MIDI Grid: Repeat current cell|MidiGrid_AuditionCell.lua
|
RSmidigrid_audit|32060|MIDI Grid: Repeat current cell|MidiGrid_AuditionCell.lua
|
||||||
@@ -101,6 +109,14 @@ keys='
|
|||||||
1|32807|32060|RSmidigrid_right
|
1|32807|32060|RSmidigrid_right
|
||||||
1|32806|32060|RSmidigrid_up
|
1|32806|32060|RSmidigrid_up
|
||||||
1|32808|32060|RSmidigrid_down
|
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
|
1|13|32060|RSmidigrid_cell
|
||||||
17|72|32060|RSmidigrid_hold
|
17|72|32060|RSmidigrid_hold
|
||||||
17|65|32060|RSmidigrid_audit
|
17|65|32060|RSmidigrid_audit
|
||||||
|
|||||||
@@ -285,6 +285,175 @@ function M.pitchesInCell(take, ppqA, ppqB)
|
|||||||
return uniq
|
return uniq
|
||||||
end
|
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
|
--------------------------------------------------------------------- edits
|
||||||
|
|
||||||
-- Toggle a note on/off at (current cell, pitch).
|
-- Toggle a note on/off at (current cell, pitch).
|
||||||
|
|||||||
+176
-1
@@ -10,7 +10,10 @@ reaper = {
|
|||||||
TimeMap_GetTimeSigAtTime = function() return 4, 4 end,
|
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 fails = 0
|
||||||
local function eq(got, want, label)
|
local function eq(got, want, label)
|
||||||
@@ -129,4 +132,176 @@ G.restoreRouting()
|
|||||||
eq(tracks[1].arm, 0, "second track handed back")
|
eq(tracks[1].arm, 0, "second track handed back")
|
||||||
eq(tracks[3].arm, 1, "bystander still armed at the end")
|
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"))
|
print(fails == 0 and "ALL PASS" or (fails .. " FAILURES"))
|
||||||
|
|||||||
Reference in New Issue
Block a user