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"))