Add diatonic chord entry and velocity control

Chords are built by stacking scale thirds rather than from a quality table,
so the chord follows the degree automatically -- in C major, Alt+3 on C gives
C major, on D gives D minor, on B gives B diminished. Toggling delegates to
toggleCell, so chord tones inherit hold-mode joining, splitting and trimming
unchanged.

Velocity keys act on the note under the cursor if there is one, and on the
default for new notes otherwise, announcing which. That avoids a mode switch
between setting a level to draw at and shaping dynamics after the fact.

Also fixes install.ps1 failing to resolve its own directory: $PSScriptRoot is
not reliably populated during parameter binding, so it is resolved in the body.

Tests cover chord quality per scale degree and range clamping.
This commit is contained in:
2026-08-14 18:18:12 +02:00
parent 8fd6f947ed
commit c9e927ca3d
8 changed files with 214 additions and 3 deletions
+19
View File
@@ -0,0 +1,19 @@
-- MIDI Grid: toggle a diatonic seventh chord on the cursor pitch
local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])")
local G = dofile(dir .. "midigrid_lib.lua")
if not G.isActive() then return end
local hwnd, take, err = G.editor()
if not take then G.say(err) return end
local ps = G.chordPitches(G.getPitch(hwnd), 4)
if not ps then G.say("No room for a chord here") return end
local state = G.toggleChord(take, ps)
local names = {}
for _, p in ipairs(ps) do names[#names + 1] = G.noteName(p) end
G.say("Seventh " .. state .. ", " .. table.concat(names, ", "))
if state == "on" then G.preview(ps) end
+19
View File
@@ -0,0 +1,19 @@
-- MIDI Grid: toggle a diatonic triad on the cursor pitch
local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])")
local G = dofile(dir .. "midigrid_lib.lua")
if not G.isActive() then return end
local hwnd, take, err = G.editor()
if not take then G.say(err) return end
local ps = G.chordPitches(G.getPitch(hwnd), 3)
if not ps then G.say("No room for a chord here") return end
local state = G.toggleChord(take, ps)
local names = {}
for _, p in ipairs(ps) do names[#names + 1] = G.noteName(p) end
G.say("Triad " .. state .. ", " .. table.concat(names, ", "))
if state == "on" then G.preview(ps) end
+18
View File
@@ -0,0 +1,18 @@
-- MIDI Grid: lower velocity of the note at the cursor, or the default
local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])")
local G = dofile(dir .. "midigrid_lib.lua")
if not G.isActive() then return end
local hwnd, take, err = G.editor()
if not take then G.say(err) return end
local p = G.getPitch(hwnd)
local what, v = G.nudgeVelocity(take, p, -G.getNum("velstep", 8))
if what == "note" then
G.say(G.noteName(p) .. " velocity " .. v)
G.preview({ p })
else
G.say("Default velocity " .. v)
end
+18
View File
@@ -0,0 +1,18 @@
-- MIDI Grid: raise velocity of the note at the cursor, or the default
local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])")
local G = dofile(dir .. "midigrid_lib.lua")
if not G.isActive() then return end
local hwnd, take, err = G.editor()
if not take then G.say(err) return end
local p = G.getPitch(hwnd)
local what, v = G.nudgeVelocity(take, p, G.getNum("velstep", 8))
if what == "note" then
G.say(G.noteName(p) .. " velocity " .. v)
G.preview({ p })
else
G.say("Default velocity " .. v)
end
+40
View File
@@ -72,6 +72,9 @@ actions by hand in Actions → Show action list. That 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 |
| `Enter` | Toggle note at cursor cell | Edit: Event properties |
| `Alt+3` | Toggle diatonic triad on cursor pitch | — |
| `Alt+7` | Toggle diatonic seventh chord | — |
| `Alt+Shift+Up` / `Alt+Shift+Down` | Velocity up / down | — |
| `Alt+G` | **Toggle grid mode on/off** | — |
| `Alt+H` | Toggle hold mode | — |
| `Alt+A` | Repeat current cell (speak + sound) | — |
@@ -112,6 +115,42 @@ it into one sustained note. Toggling a cell off in the middle of a sustained
run splits it in two; at either end, it shortens it. So you can draw a long
note by arrowing right and pressing Enter across several cells.
## Chords
`Alt+3` stamps a **diatonic triad** on the cursor pitch; `Alt+7` a seventh
chord. Both are built by stacking scale thirds, so the quality follows the
degree automatically — in C major, `Alt+3` on C gives C major, on D gives D
minor, on B gives B diminished. There is no chord-quality table to configure
and no way to land on a chord outside the key.
If every tone of the chord is already in the cell, the keystroke removes them
all; otherwise it fills in whatever is missing. Chord tones obey hold mode,
splitting and trimming exactly like single notes.
You can also build chords by hand at any time — arrow to a pitch, press Enter,
arrow to another, press Enter. The cursor never moves horizontally when you
toggle, so stacking notes in one cell has always worked. `Alt+3` and `Alt+7`
are just the one-keystroke version.
In the chromatic scale, "stacking thirds" degenerates into stacked whole
tones. Predictable, but not musically useful — pick a real scale first.
## Velocity
`Alt+Shift+Up` and `Alt+Shift+Down` change velocity in steps of 8.
The key does one of two things depending on where you are, and says which:
- **On a note** — changes that note's velocity: *"E5 velocity 104"*, and
replays it at the new level so you can hear the change.
- **On an empty cell** — changes the default for newly inserted notes:
*"Default velocity 104"*.
So you can set a level and then draw at it, or draw first and shape the
dynamics afterwards, without a mode switch either way.
Set `velstep` in `ExtState` if steps of 8 are too coarse or too fine.
## Scales
`Alt+S` prompts for a root (`C`, `F#`, `Bb`…) and a scale number. Up and down
@@ -145,6 +184,7 @@ In `ExtState` section `midigrid`:
| Key | Default | Meaning |
|---|---|---|
| `vel` | 96 | Velocity of inserted notes |
| `velstep` | 8 | Velocity change per keypress |
| `chan` | 0 | MIDI channel (0-based) |
| `previewdur` | 0.4 | Preview note length, seconds |
+19 -3
View File
@@ -14,13 +14,21 @@
#>
[CmdletBinding()]
param(
[string] $ScriptDir = $PSScriptRoot,
[string] $ScriptDir,
[string] $ResourcePath = "$env:APPDATA\REAPER",
[switch] $Uninstall
)
$ErrorActionPreference = 'Stop'
# $PSScriptRoot is not reliably populated during parameter binding, so
# resolve the script's own directory here instead of in the param block.
if (-not $ScriptDir) {
$ScriptDir = if ($PSScriptRoot) { $PSScriptRoot }
else { Split-Path -Parent $MyInvocation.MyCommand.Definition }
}
if (-not $ScriptDir) { throw "Could not determine script directory; pass -ScriptDir." }
$kb = Join-Path $ResourcePath 'reaper-kb.ini'
if (-not (Test-Path $kb)) { throw "reaper-kb.ini not found at $kb" }
@@ -40,7 +48,11 @@ $actions = @(
@('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'),
@('RSmidigrid_scale', 32060, 'MIDI Grid: Set root and scale', 'MidiGrid_SetScale.lua')
@('RSmidigrid_scale', 32060, 'MIDI Grid: Set root and scale', 'MidiGrid_SetScale.lua'),
@('RSmidigrid_triad', 32060, 'MIDI Grid: Toggle diatonic triad', 'MidiGrid_ChordTriad.lua'),
@('RSmidigrid_seventh',32060, 'MIDI Grid: Toggle diatonic seventh chord', 'MidiGrid_ChordSeventh.lua'),
@('RSmidigrid_velup', 32060, 'MIDI Grid: Velocity up', 'MidiGrid_VelUp.lua'),
@('RSmidigrid_veldn', 32060, 'MIDI Grid: Velocity down', 'MidiGrid_VelDown.lua')
)
# flags, keycode, section, id
@@ -56,7 +68,11 @@ $keys = @(
@(1, 13, 32060, 'RSmidigrid_cell'),
@(17, 72, 32060, 'RSmidigrid_hold'),
@(17, 65, 32060, 'RSmidigrid_audit'),
@(17, 83, 32060, 'RSmidigrid_scale')
@(17, 83, 32060, 'RSmidigrid_scale'),
@(17, 51, 32060, 'RSmidigrid_triad'),
@(17, 55, 32060, 'RSmidigrid_seventh'),
@(21, 32806, 32060, 'RSmidigrid_velup'),
@(21, 32808, 32060, 'RSmidigrid_veldn')
)
$backup = "$kb.midigrid-backup"
+64
View File
@@ -352,6 +352,70 @@ function M.toggleCell(take, pitch)
return result, detail
end
--------------------------------------------------------------------- chords
-- Stack `count` diatonic chord tones on `pitch`, skipping a scale degree
-- between each -- so in a major scale, degree 1 gives a major triad and
-- degree 2 a minor one, exactly as harmony expects. In the chromatic scale
-- this degenerates to stacked whole tones, which is arguably useless but at
-- least predictable.
function M.chordPitches(pitch, count)
local ps = { pitch }
local p = pitch
for _ = 2, count do
local a = M.scaleStep(p, 1); if not a then return nil end
local b = M.scaleStep(a, 1); if not b then return nil end
p = b
ps[#ps + 1] = p
end
return ps
end
-- Toggle a whole chord in the current cell. If every tone is already
-- present the chord is removed; otherwise the missing tones are filled in.
-- Delegates to toggleCell so hold mode, splitting and trimming all behave
-- identically to single notes.
function M.toggleChord(take, pitches)
local ppqA, ppqB = M.cellPPQ(take)
local allPresent = true
for _, p in ipairs(pitches) do
if not M.noteInCell(take, p, ppqA, ppqB) then allPresent = false break end
end
reaper.Undo_BeginBlock2(0)
local changed = {}
for _, p in ipairs(pitches) do
local here = M.noteInCell(take, p, ppqA, ppqB) ~= nil
if allPresent or not here then
M.toggleCell(take, p)
changed[#changed + 1] = p
end
end
reaper.Undo_EndBlock2(0, "MIDI Grid: toggle chord", -1)
return (allPresent and "off" or "on"), changed
end
------------------------------------------------------------------ velocity
-- Adjust velocity by delta. If a note sits at the cursor cell and pitch, its
-- velocity changes; otherwise the default for newly inserted notes does.
-- Returns "note" or "default", and the resulting value.
function M.nudgeVelocity(take, pitch, delta)
local ppqA, ppqB = M.cellPPQ(take)
local idx, _, _, _, vel = M.noteInCell(take, pitch, ppqA, ppqB)
if idx then
local v = math.max(1, math.min(127, math.floor(vel + delta)))
reaper.Undo_BeginBlock2(0)
reaper.MIDI_SetNote(take, idx, nil, nil, nil, nil, nil, nil, v, true)
reaper.MIDI_Sort(take)
reaper.Undo_EndBlock2(0, "MIDI Grid: set velocity", -1)
return "note", v
end
local v = math.max(1, math.min(127, math.floor(M.getNum("vel", 96) + delta)))
M.set("vel", v)
return "default", v
end
------------------------------------------------------------------ audition
--[[
+17
View File
@@ -67,4 +67,21 @@ 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")
print(fails == 0 and "ALL PASS" or (fails .. " FAILURES"))