From c9e927ca3d11e5dacf9838b0a0c365ff7e687cf1 Mon Sep 17 00:00:00 2001 From: Talon Date: Fri, 14 Aug 2026 18:18:12 +0200 Subject: [PATCH] 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. --- MidiGrid_ChordSeventh.lua | 19 ++++++++++++ MidiGrid_ChordTriad.lua | 19 ++++++++++++ MidiGrid_VelDown.lua | 18 +++++++++++ MidiGrid_VelUp.lua | 18 +++++++++++ README.md | 40 ++++++++++++++++++++++++ install.ps1 | 22 ++++++++++++-- midigrid_lib.lua | 64 +++++++++++++++++++++++++++++++++++++++ test_lib.lua | 17 +++++++++++ 8 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 MidiGrid_ChordSeventh.lua create mode 100644 MidiGrid_ChordTriad.lua create mode 100644 MidiGrid_VelDown.lua create mode 100644 MidiGrid_VelUp.lua diff --git a/MidiGrid_ChordSeventh.lua b/MidiGrid_ChordSeventh.lua new file mode 100644 index 0000000..58428f6 --- /dev/null +++ b/MidiGrid_ChordSeventh.lua @@ -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 diff --git a/MidiGrid_ChordTriad.lua b/MidiGrid_ChordTriad.lua new file mode 100644 index 0000000..542bd12 --- /dev/null +++ b/MidiGrid_ChordTriad.lua @@ -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 diff --git a/MidiGrid_VelDown.lua b/MidiGrid_VelDown.lua new file mode 100644 index 0000000..7ccb621 --- /dev/null +++ b/MidiGrid_VelDown.lua @@ -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 diff --git a/MidiGrid_VelUp.lua b/MidiGrid_VelUp.lua new file mode 100644 index 0000000..09ccf5b --- /dev/null +++ b/MidiGrid_VelUp.lua @@ -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 diff --git a/README.md b/README.md index 373db65..ef2d401 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/install.ps1 b/install.ps1 index 7242977..85c65ba 100644 --- a/install.ps1 +++ b/install.ps1 @@ -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" diff --git a/midigrid_lib.lua b/midigrid_lib.lua index 51fbf67..03a9831 100644 --- a/midigrid_lib.lua +++ b/midigrid_lib.lua @@ -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 --[[ diff --git a/test_lib.lua b/test_lib.lua index e38b937..403a15b 100644 --- a/test_lib.lua +++ b/test_lib.lua @@ -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"))