Accessible grid-based MIDI entry mode for REAPER

Keyboard- and speech-driven step entry for the MIDI editor, built for use
with OSARA. Arrows walk grid cells and scale degrees, Enter toggles notes,
and every position is spoken and auditioned.

Uses REAPER's own edit cursor and active_note_row rather than private cursor
state, so grid mode and OSARA editing stay in sync. Grid size comes from
MIDI_GetGrid(), so existing grid keybindings drive it. When grid mode is off,
every bound key forwards to its previous action.

Preview timing lives in a background daemon so the action scripts can exit
immediately -- a script still alive on the next keypress triggers REAPER's
"already running" prompt and breaks key repeat.
This commit is contained in:
2026-08-14 18:13:45 +02:00
commit 8fd6f947ed
17 changed files with 1046 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
*.bak
*.midigrid-backup
+21
View File
@@ -0,0 +1,21 @@
SCR 4 0 RSmidigrid_open "MIDI Grid: Open selected item in grid mode" "D:\code\midigrid\MidiGrid_OpenInGrid.lua"
SCR 4 0 RSmidigrid_daemon "MIDI Grid: Preview daemon (auto-started)" "D:\code\midigrid\MidiGrid_Daemon.lua"
SCR 4 32060 RSmidigrid_mode "MIDI Grid: Toggle grid mode" "D:\code\midigrid\MidiGrid_ToggleMode.lua"
SCR 4 32060 RSmidigrid_left "MIDI Grid: Cursor left one cell" "D:\code\midigrid\MidiGrid_Left.lua"
SCR 4 32060 RSmidigrid_right "MIDI Grid: Cursor right one cell" "D:\code\midigrid\MidiGrid_Right.lua"
SCR 4 32060 RSmidigrid_up "MIDI Grid: Pitch up one scale degree" "D:\code\midigrid\MidiGrid_Up.lua"
SCR 4 32060 RSmidigrid_down "MIDI Grid: Pitch down one scale degree" "D:\code\midigrid\MidiGrid_Down.lua"
SCR 4 32060 RSmidigrid_cell "MIDI Grid: Toggle note at cursor cell" "D:\code\midigrid\MidiGrid_ToggleCell.lua"
SCR 4 32060 RSmidigrid_hold "MIDI Grid: Toggle hold mode" "D:\code\midigrid\MidiGrid_ToggleHold.lua"
SCR 4 32060 RSmidigrid_audit "MIDI Grid: Repeat current cell" "D:\code\midigrid\MidiGrid_AuditionCell.lua"
SCR 4 32060 RSmidigrid_scale "MIDI Grid: Set root and scale" "D:\code\midigrid\MidiGrid_SetScale.lua"
KEY 21 71 _RSmidigrid_open 0
KEY 17 71 _RSmidigrid_mode 32060
KEY 1 32805 _RSmidigrid_left 32060
KEY 1 32807 _RSmidigrid_right 32060
KEY 1 32806 _RSmidigrid_up 32060
KEY 1 32808 _RSmidigrid_down 32060
KEY 1 13 _RSmidigrid_cell 32060
KEY 17 72 _RSmidigrid_hold 32060
KEY 17 65 _RSmidigrid_audit 32060
KEY 17 83 _RSmidigrid_scale 32060
+13
View File
@@ -0,0 +1,13 @@
-- MIDI Grid: re-speak and re-sound the current cell without moving
local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])")
local G = dofile(dir .. "midigrid_lib.lua")
local hwnd, take, err = G.editor()
if not take then G.say(err) return end
local ppqA, ppqB = G.cellPPQ(take)
local ps = G.pitchesInCell(take, ppqA, ppqB)
G.say(("%s, %s, cursor %s"):format(
G.cellPosText(take), G.cellContentText(take), G.noteName(G.getPitch(hwnd))))
G.preview(ps)
+57
View File
@@ -0,0 +1,57 @@
--[[
MIDI Grid: preview daemon.
Runs quietly in the background and owns every preview note-on/note-off.
The action scripts never defer, so they always exit instantly and Reaper
never shows its "already running" prompt no matter how fast keys repeat.
Started automatically by the action scripts; you never need to run it.
]]
local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])")
local G = dofile(dir .. "midigrid_lib.lua")
-- Refuse to start a second copy.
local hb = tonumber(G.getTemp("hb", "0")) or 0
if os.time() - hb <= 3 then return end
local sounding = {}
local releaseAt = 0
local lastSeq = tonumber(G.getTemp("seq", "0")) or 0
local function allOff()
for _, p in ipairs(sounding) do
reaper.StuffMIDIMessage(0, 0x80, p, 0)
end
sounding = {}
end
local function loop()
G.setTemp("hb", os.time())
local seq = tonumber(G.getTemp("seq", "0")) or 0
if seq ~= lastSeq then
lastSeq = seq
-- A new request cuts whatever is still sounding, so fast arrowing
-- retriggers cleanly instead of piling notes up.
allOff()
local vel = math.floor(G.getNum("vel", 96))
for s in tostring(G.getTemp("pitches", "")):gmatch("%d+") do
local p = tonumber(s)
if p and p >= 0 and p <= 127 then
sounding[#sounding + 1] = p
reaper.StuffMIDIMessage(0, 0x90, p, vel)
end
end
releaseAt = reaper.time_precise() + (tonumber(G.getTemp("dur", "0.4")) or 0.4)
end
if #sounding > 0 and reaper.time_precise() >= releaseAt then
allOff()
end
reaper.defer(loop)
end
-- Never leave a note hanging if the daemon is terminated or Reaper quits.
reaper.atexit(allOff)
loop()
+17
View File
@@ -0,0 +1,17 @@
-- MIDI Grid: pitch down one scale degree (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
local hwnd, take, err = G.editor()
if not take then G.say(err) return end
local p = G.scaleStep(G.getPitch(hwnd), -1)
if not p then G.say("bottom") return end
G.setPitch(hwnd, p)
local ppqA, ppqB = G.cellPPQ(take)
local on = G.noteInCell(take, p, ppqA, ppqB) ~= nil
G.say(G.noteName(p) .. (on and ", on" or ""))
G.preview({ p })
+18
View File
@@ -0,0 +1,18 @@
-- MIDI Grid: cursor left one 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_PREVCHORD") return end
local hwnd, take, err = G.editor()
if not take then G.say(err) return end
local _, _, idx = G.cell(take)
if idx <= 0 then G.say("start") return end
G.gotoCell(take, idx - 1)
local content = G.cellContentText(take)
G.say(G.cellPosText(take) .. ", " .. content)
local ppqA, ppqB = G.cellPPQ(take)
G.preview(G.pitchesInCell(take, ppqA, ppqB))
+40
View File
@@ -0,0 +1,40 @@
--[[
MIDI Grid: open the selected item in the MIDI editor and enter grid mode.
This is the counterpart to the normal "open in MIDI editor" action: it does
the same thing, then arms grid mode and announces the starting state, so a
single keystroke takes you from a selected item to writing notes.
]]
local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])")
local G = dofile(dir .. "midigrid_lib.lua")
local item = reaper.GetSelectedMediaItem(0, 0)
if not item then G.say("No item selected") return end
local take = reaper.GetActiveTake(item)
if not take or not reaper.TakeIsMIDI(take) then G.say("Selected item is not MIDI") return end
-- Item: Open in built-in MIDI editor (set default behaviour in preferences)
reaper.Main_OnCommand(40153, 0)
local hwnd = reaper.MIDIEditor_GetActive()
if not hwnd then G.say("Could not open MIDI editor") return end
G.set("active", 1)
-- Start at the item's beginning so the first cell is the item's first cell.
local pos = reaper.GetMediaItemInfo_Value(item, "D_POSITION")
reaper.SetEditCurPos(pos, true, false)
local p = G.getPitch(hwnd)
if not G.inScale(p) then
local up = G.scaleStep(p, 1)
if up then p = up ; G.setPitch(hwnd, p) end
end
G.say(("Grid mode on, %s, grid %s, %s, %s, %s"):format(
G.scaleName(),
G.gridLabel(G.gridQN(take)),
G.isHold() and "hold on" or "hold off",
G.noteName(p),
G.cellPosText(take)))
+17
View File
@@ -0,0 +1,17 @@
-- MIDI Grid: cursor right one 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_NEXTCHORD") return end
local hwnd, take, err = G.editor()
if not take then G.say(err) return end
local _, _, idx = G.cell(take)
G.gotoCell(take, idx + 1)
local content = G.cellContentText(take)
G.say(G.cellPosText(take) .. ", " .. content)
local ppqA, ppqB = G.cellPPQ(take)
G.preview(G.pitchesInCell(take, ppqA, ppqB))
+35
View File
@@ -0,0 +1,35 @@
-- MIDI Grid: choose the root and scale that up/down navigate by
local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])")
local G = dofile(dir .. "midigrid_lib.lua")
local names = {}
for i, s in ipairs(G.SCALES) do names[#names + 1] = i .. "=" .. s.name end
local ok, csv = reaper.GetUserInputs(
"MIDI Grid scale", 2,
"Root note (C, F#, Bb...),Scale number (" .. table.concat(names, " ") .. "),extrawidth=320",
G.noteNamePc(G.scaleRoot()) .. "," .. G.scaleIndex())
if not ok then return end
local rootStr, scaleStr = csv:match("^([^,]*),(.*)$")
-- Parse a root spelled with either sharps or flats.
local BASE = { c = 0, d = 2, e = 4, f = 5, g = 7, a = 9, b = 11 }
local letter, accid = tostring(rootStr):match("^%s*(%a)([#b]?)")
if not letter or not BASE[letter:lower()] then
G.say("Unrecognised root note")
return
end
local pc = BASE[letter:lower()]
if accid == "#" then pc = pc + 1 elseif accid == "b" then pc = pc - 1 end
pc = pc % 12
local si = math.floor(tonumber(scaleStr) or 0)
if si < 1 or si > #G.SCALES then
G.say("Scale number must be 1 to " .. #G.SCALES)
return
end
G.set("root", pc)
G.set("scale", si)
G.say("Scale set to " .. G.scaleName())
+18
View File
@@ -0,0 +1,18 @@
-- MIDI Grid: toggle a note at the cursor cell (pass-through when off)
local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])")
local G = dofile(dir .. "midigrid_lib.lua")
if not G.isActive() then G.passThrough(40004) return end -- Edit: Event properties
local hwnd, take, err = G.editor()
if not take then G.say(err) return end
local p = G.getPitch(hwnd)
local state, detail = G.toggleCell(take, p)
if state == "on" then
G.say(G.noteName(p) .. " on, " .. detail)
G.preview({ p })
else
G.say(G.noteName(p) .. " off, " .. detail)
end
+7
View File
@@ -0,0 +1,7 @@
-- MIDI Grid: toggle hold mode (adjacent cells join into one sustained note)
local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])")
local G = dofile(dir .. "midigrid_lib.lua")
local now = not G.isHold()
G.set("hold", now and 1 or 0)
G.say(now and "Hold on, adjacent notes join" or "Hold off, adjacent notes repeat")
+34
View File
@@ -0,0 +1,34 @@
-- MIDI Grid: toggle grid entry mode on/off
local dir = ({ reaper.get_action_context() })[2]:match("@?(.*[\\/])")
local G = dofile(dir .. "midigrid_lib.lua")
local now = not G.isActive()
G.set("active", now and 1 or 0)
if not now then
G.say("Grid mode off")
return
end
local hwnd, take, err = G.editor()
if not take then
-- Mode is still armed; it simply has nothing to act on yet.
G.say("Grid mode on, " .. err)
return
end
-- Park the pitch cursor on a scale tone so the very first up/down press
-- moves by a sensible degree rather than off a foreign note.
local p = G.getPitch(hwnd)
if not G.inScale(p) then
local up = G.scaleStep(p, 1)
if up then p = up ; G.setPitch(hwnd, p) end
end
local g = G.gridQN(take)
G.say(("Grid mode on, %s, grid %s, %s, %s, %s"):format(
G.scaleName(),
G.gridLabel(g),
G.isHold() and "hold on" or "hold off",
G.noteName(p),
G.cellPosText(take)))
+17
View File
@@ -0,0 +1,17 @@
-- MIDI Grid: pitch up one scale degree (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
local hwnd, take, err = G.editor()
if not take then G.say(err) return end
local p = G.scaleStep(G.getPitch(hwnd), 1)
if not p then G.say("top") return end
G.setPitch(hwnd, p)
local ppqA, ppqB = G.cellPPQ(take)
local on = G.noteInCell(take, p, ppqA, ppqB) ~= nil
G.say(G.noteName(p) .. (on and ", on" or ""))
G.preview({ p })
+171
View File
@@ -0,0 +1,171 @@
# MIDI Grid — accessible quick MIDI entry for REAPER
A grid / step-sequencer entry mode for REAPER's MIDI editor, built for
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.
Everything you land on is spoken and sounded, so you can hear what is already
there as you move.
It is **not** a replacement for the MIDI editor. It is a fast way to block out
a clip; switch grid mode off and OSARA's normal editing is right there,
unchanged, on the same cursor.
## Requirements
- REAPER
- **OSARA** — for speech output
- **SWS** — for the note-naming octave preference
No other extensions. No ReaPack, no js_ReaScriptAPI, no ReaImGui.
## Install
Clone or download this repository anywhere you like, then:
1. **Close REAPER completely.** It rewrites `reaper-kb.ini` when it exits and
will throw away anything written while it is running.
2. Run:
```powershell
powershell -ExecutionPolicy Bypass -File install.ps1
```
3. Start REAPER, select a MIDI item, press **`Alt+Shift+G`**.
You should hear something like *"Grid mode on, C major, grid 1/16, hold off,
C5, 1.1.00"*.
To remove it again: `powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall`
The installer backs up `reaper-kb.ini` to `reaper-kb.ini.midigrid-backup`
before its first change, and is safe to re-run.
### Why not "Import key map"?
`MidiGrid.ReaperKeyMap` is included for reference, but **importing it does not
reliably work**, and the way it fails is silent and confusing: REAPER accepts
the file, registers the script actions, and then converts every key binding it
cannot resolve into a `No-op (no action)` entry. The keys appear bound in the
action list but do nothing.
The cause is that a `KEY` line must reference the *named command* — the `SCR`
id with a **leading underscore** (`_RSmidigrid_open`), not the bare id
(`RSmidigrid_open`). `install.ps1` writes the underscored form directly.
If you would rather not run a script, you can bind the ten `MIDI Grid:`
actions by hand in Actions → Show action list. That always works.
## Keys
**Main section**
| Key | Action |
|---|---|
| `Alt+Shift+G` | Open selected item in grid mode |
**MIDI editor section**
| Key | Grid mode ON | Grid mode OFF (passes through to) |
|---|---|---|
| `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+G` | **Toggle grid mode on/off** | — |
| `Alt+H` | Toggle hold mode | — |
| `Alt+A` | Repeat current cell (speak + sound) | — |
| `Alt+S` | Set root and scale | — |
| `1``9` | Grid size — REAPER's own grid actions | same |
`Alt+Shift+G` **opens** grid mode; it does not close it. Use `Alt+G` inside
the editor to toggle back off.
## How it integrates
- **Grid size is REAPER's grid.** Read via `MIDI_GetGrid()`, so whatever you
already have bound to the MIDI editor's grid actions (by default `1` = 1/1,
`2` = 1/2, `4` = 1/4, `6` = 1/16, `8` = 1/8, `3` = 1/32) drives this too.
Nothing new to learn.
- **The cursor is REAPER's cursor.** Time is the native edit cursor; pitch is
the MIDI editor's `active_note_row`. No private cursor state exists, so grid
mode and OSARA cannot drift out of sync — you can switch between them
mid-phrase.
- **Turning grid mode off restores everything.** Each bound key forwards to
what it did before, so nothing is permanently taken away.
## What gets spoken
- **Left / right** — cell position and everything sounding in it:
`"3.2.00, C5, E5"`, and those notes are sounded together.
- **Up / down** — the note name, plus `"on"` if this cell already holds that
pitch: `"E5, on"`.
- **Enter** — `"E5 on, added"` or `"E5 off, removed"`.
## Hold mode
With hold **off**, toggling adjacent cells at the same pitch gives separate
repeated notes.
With hold **on**, a new note touching an existing note of the same pitch joins
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.
## Scales
`Alt+S` prompts for a root (`C`, `F#`, `Bb`…) and a scale number. Up and down
then move by scale degree, which is what makes entry fast — you cannot land on
a wrong note by accident. Pick `chromatic` for plain semitone movement.
If the cursor sits on a note outside the current scale, up/down move to the
nearest scale tone in that direction rather than getting stuck.
## Architecture notes
**The action scripts never call `reaper.defer`.** This matters. If a script is
still alive when you press its key again, REAPER interrupts you with a "script
is already running — terminate or launch new instance?" prompt, which makes
fast key repeat impossible and, if you answer "terminate" and tick remember,
permanently costs you every second keypress.
So preview note timing lives in `MidiGrid_Daemon.lua`, a single background loop
that auto-starts on first use and relaunches itself if it ever dies. Action
scripts post a request to transient `ExtState` and exit within a millisecond. A
new request cuts the previous note, so fast arrowing retriggers cleanly. The
daemon releases everything on `atexit`, so notes cannot hang.
Preview requests use **non-persisted** `ExtState` — persisting them would write
to `reaper.ini` on every keypress.
## Tunables
In `ExtState` section `midigrid`:
| Key | Default | Meaning |
|---|---|---|
| `vel` | 96 | Velocity of inserted notes |
| `chan` | 0 | MIDI channel (0-based) |
| `previewdur` | 0.4 | Preview note length, seconds |
## Tests
`test_lib.lua` covers the pure logic — scale stepping, note naming, grid
labels — against a stubbed `reaper` table, so it runs outside REAPER:
```
lua test_lib.lua
```
Everything touching MIDI data or the REAPER API is only exercised in REAPER
itself.
## Limitations
- Preview uses `StuffMIDIMessage`, which plays through the track's instrument
via the virtual-keyboard input, so the track must be armed / monitored —
exactly as with REAPER's own MIDI preview.
- The grid aligns to the project timeline (bar lines), not the item start. For
an item starting off-grid, the first cell will be partial.
- Note octave naming follows the `midioctoffs` preference; with the default,
note 60 reads as C5.
+106
View File
@@ -0,0 +1,106 @@
<#
MIDI Grid installer.
Registers the scripts as Reaper actions and binds them, by editing
reaper-kb.ini directly. This is done rather than via "Import key map"
because Reaper's importer silently converts unresolved script bindings
into "No-op (no action)" entries -- see README, Installation notes.
REAPER MUST BE CLOSED. It rewrites reaper-kb.ini on exit and will
discard anything written while it is running.
Usage: powershell -ExecutionPolicy Bypass -File install.ps1
powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall
#>
[CmdletBinding()]
param(
[string] $ScriptDir = $PSScriptRoot,
[string] $ResourcePath = "$env:APPDATA\REAPER",
[switch] $Uninstall
)
$ErrorActionPreference = 'Stop'
$kb = Join-Path $ResourcePath 'reaper-kb.ini'
if (-not (Test-Path $kb)) { throw "reaper-kb.ini not found at $kb" }
if (Get-Process reaper* -ErrorAction SilentlyContinue) {
throw "REAPER is running. Close it completely and run this again."
}
# id, section, description, script filename
$actions = @(
@('RSmidigrid_open', 0, 'MIDI Grid: Open selected item in grid mode', 'MidiGrid_OpenInGrid.lua'),
@('RSmidigrid_daemon', 0, 'MIDI Grid: Preview daemon (auto-started)', 'MidiGrid_Daemon.lua'),
@('RSmidigrid_mode', 32060, 'MIDI Grid: Toggle grid mode', 'MidiGrid_ToggleMode.lua'),
@('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_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')
)
# flags, keycode, section, id
# flags: 1 base, +4 Shift, +8 Ctrl, +16 Alt
# section: 0 = Main, 32060 = MIDI Editor
$keys = @(
@(21, 71, 0, 'RSmidigrid_open'),
@(17, 71, 32060, 'RSmidigrid_mode'),
@(1, 32805, 32060, 'RSmidigrid_left'),
@(1, 32807, 32060, 'RSmidigrid_right'),
@(1, 32806, 32060, 'RSmidigrid_up'),
@(1, 32808, 32060, 'RSmidigrid_down'),
@(1, 13, 32060, 'RSmidigrid_cell'),
@(17, 72, 32060, 'RSmidigrid_hold'),
@(17, 65, 32060, 'RSmidigrid_audit'),
@(17, 83, 32060, 'RSmidigrid_scale')
)
$backup = "$kb.midigrid-backup"
if (-not (Test-Path $backup)) {
Copy-Item $kb $backup
Write-Host "Backed up original to $backup"
}
$lines = [System.Collections.Generic.List[string]]::new()
$lines.AddRange([System.IO.File]::ReadAllLines($kb))
# Drop any previous MIDI Grid lines so re-running is idempotent.
for ($i = $lines.Count - 1; $i -ge 0; $i--) {
if ($lines[$i] -match '(?i)RSmidigrid') { $lines.RemoveAt($i) }
}
if ($Uninstall) {
[System.IO.File]::WriteAllLines($kb, $lines, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "Removed MIDI Grid actions and bindings."
Write-Host "Note: keys it had taken over (arrows, Enter) are now unbound in the"
Write-Host "MIDI editor. Rebind them, or restore $backup."
return
}
foreach ($a in $actions) {
$path = Join-Path $ScriptDir $a[3]
if (-not (Test-Path $path)) { throw "Missing script: $path" }
$lines.Insert(0, ('SCR 4 {0} {1} "{2}" {3}' -f $a[1], $a[0], $a[2], $path))
}
# KEY lines reference the *named command*, which is the SCR id with a
# leading underscore. Without it Reaper cannot resolve the binding and the
# key silently does nothing.
foreach ($k in $keys) {
$new = 'KEY {0} {1} _{3} {2}' -f $k[0], $k[1], $k[2], $k[3]
$pat = '^KEY {0} {1} \S+ {2}(\s|$)' -f $k[0], $k[1], $k[2]
$found = $false
for ($i = 0; $i -lt $lines.Count; $i++) {
if ($lines[$i] -match $pat) { $lines[$i] = $new; $found = $true; break }
}
if (-not $found) { $lines.Add($new) }
}
[System.IO.File]::WriteAllLines($kb, $lines, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "Installed $($actions.Count) actions and $($keys.Count) key bindings."
Write-Host "Start REAPER, select a MIDI item, and press Alt+Shift+G."
+403
View File
@@ -0,0 +1,403 @@
--[[
midigrid_lib.lua -- shared library for the MIDI Grid quick-entry mode.
Design notes:
* No window, no gfx. All feedback goes through OSARA speech.
* The cursor is Reaper's OWN edit cursor (time) plus the MIDI editor's
active_note_row (pitch). We never keep a private cursor, so grid mode
and normal OSARA editing can be interleaved freely.
* Grid size is read from MIDI_GetGrid(), so the user's existing
grid-size keybindings (1-9 in the MIDI editor) drive it.
* Persistent state is only: active, hold, root, scale, vel, chan.
]]
local M = {}
local EXT = "midigrid"
--------------------------------------------------------------------- state
function M.get(k, default)
local v = reaper.GetExtState(EXT, k)
if v == "" then return default end
return v
end
function M.getNum(k, default)
return tonumber(M.get(k, tostring(default))) or default
end
function M.set(k, v)
reaper.SetExtState(EXT, k, tostring(v), true)
end
-- Transient state (preview requests, daemon heartbeat). Never persisted:
-- these change on every keypress and must not hit reaper.ini.
function M.setTemp(k, v) reaper.SetExtState(EXT, k, tostring(v), false) end
function M.getTemp(k, default)
local v = reaper.GetExtState(EXT, k)
if v == "" then return default end
return v
end
function M.isActive() return M.get("active", "0") == "1" end
function M.isHold() return M.get("hold", "0") == "1" end
--------------------------------------------------------------------- speech
-- OSARA exposes osara_outputMessage; fall back to the console if absent so
-- the scripts are still debuggable on a machine without OSARA.
function M.say(msg)
if reaper.APIExists("osara_outputMessage") then
reaper.osara_outputMessage(msg)
else
reaper.ShowConsoleMsg(tostring(msg) .. "\n")
end
end
--------------------------------------------------------------- editor/take
-- Returns hwnd, take for the active MIDI editor, or nil plus a reason.
function M.editor()
local hwnd = reaper.MIDIEditor_GetActive()
if not hwnd then return nil, nil, "No MIDI editor open" end
local take = reaper.MIDIEditor_GetTake(hwnd)
if not take or not reaper.TakeIsMIDI(take) then
return nil, nil, "No MIDI take in editor"
end
return hwnd, take, nil
end
-- Forward a keystroke to whatever it would normally do when grid mode is off.
-- Accepts a numeric command id or a named command string such as
-- "_OSARA_NEXTCHORD".
function M.passThrough(cmd)
local hwnd = reaper.MIDIEditor_GetActive()
local id = cmd
if type(cmd) == "string" then id = reaper.NamedCommandLookup(cmd) end
if not id or id == 0 then return false end
if hwnd then
reaper.MIDIEditor_OnCommand(hwnd, id)
else
reaper.Main_OnCommand(id, 0)
end
return true
end
----------------------------------------------------------------- note names
local PC_NAMES = { "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B" }
-- Reaper's displayed octave depends on the "midioctoffs" preference so that
-- our speech matches what OSARA and the editor say. With the default offset
-- of 1, note 60 reads as C5.
local function octaveOffset()
if reaper.SNM_GetIntConfigVar then
return reaper.SNM_GetIntConfigVar("midioctoffs", 1)
end
return 1
end
function M.noteName(pitch)
local pc = pitch % 12
local oct = math.floor(pitch / 12) - 1 + octaveOffset()
return PC_NAMES[pc + 1] .. tostring(oct)
end
--------------------------------------------------------------------- scales
M.SCALES = {
{ name = "major", steps = { 0, 2, 4, 5, 7, 9, 11 } },
{ name = "natural minor", steps = { 0, 2, 3, 5, 7, 8, 10 } },
{ name = "harmonic minor", steps = { 0, 2, 3, 5, 7, 8, 11 } },
{ name = "melodic minor", steps = { 0, 2, 3, 5, 7, 9, 11 } },
{ name = "dorian", steps = { 0, 2, 3, 5, 7, 9, 10 } },
{ name = "phrygian", steps = { 0, 1, 3, 5, 7, 8, 10 } },
{ name = "lydian", steps = { 0, 2, 4, 6, 7, 9, 11 } },
{ name = "mixolydian", steps = { 0, 2, 4, 5, 7, 9, 10 } },
{ name = "locrian", steps = { 0, 1, 3, 5, 6, 8, 10 } },
{ name = "major pentatonic", steps = { 0, 2, 4, 7, 9 } },
{ name = "minor pentatonic", steps = { 0, 3, 5, 7, 10 } },
{ name = "blues", steps = { 0, 3, 5, 6, 7, 10 } },
{ name = "whole tone", steps = { 0, 2, 4, 6, 8, 10 } },
{ name = "chromatic", steps = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11 } },
}
function M.scaleIndex()
local n = M.getNum("scale", 1)
if n < 1 or n > #M.SCALES then n = 1 end
return math.floor(n)
end
function M.scaleRoot() return math.floor(M.getNum("root", 0)) % 12 end
function M.scaleName()
return M.noteNamePc(M.scaleRoot()) .. " " .. M.SCALES[M.scaleIndex()].name
end
function M.noteNamePc(pc) return PC_NAMES[(pc % 12) + 1] end
-- Set of pitch classes belonging to the current scale.
local function scalePcSet()
local root = M.scaleRoot()
local set = {}
for _, s in ipairs(M.SCALES[M.scaleIndex()].steps) do
set[(root + s) % 12] = true
end
return set
end
function M.inScale(pitch) return scalePcSet()[pitch % 12] == true end
-- Step to the next pitch of the current scale in direction dir (+1 / -1).
-- Notes already off-scale simply move to the nearest scale tone in that
-- direction, so foreign notes never trap the cursor.
function M.scaleStep(pitch, dir)
local set = scalePcSet()
local p = pitch
for _ = 1, 24 do
p = p + dir
if p < 0 or p > 127 then return nil end
if set[p % 12] then return p end
end
return nil
end
------------------------------------------------------------ cursor / pitch
function M.getPitch(hwnd)
local p = reaper.MIDIEditor_GetSetting_int(hwnd, "active_note_row")
if not p or p < 0 or p > 127 then p = 60 end
return p
end
function M.setPitch(hwnd, pitch)
reaper.MIDIEditor_SetSetting_int(hwnd, "active_note_row", pitch)
end
------------------------------------------------------------------ the grid
-- Grid length in quarter notes, as configured in the MIDI editor.
function M.gridQN(take)
local grid = reaper.MIDI_GetGrid(take)
if not grid or grid <= 0 then grid = 1 end
return grid
end
-- Speak grid sizes as musical fractions ("1/16", "1 bar") rather than raw
-- quarter-note counts.
function M.gridLabel(g)
-- Length of one bar in quarter notes, at the cursor's time signature.
local num = 4
local ts_num, ts_den = reaper.TimeMap_GetTimeSigAtTime(0, reaper.GetCursorPosition())
if ts_num and ts_den and ts_den > 0 then num = ts_num * (4 / ts_den) end
if math.abs(g - num) < 1e-6 then return "1 bar" end
if g > num then
local bars = g / num
return ("%g bars"):format(bars)
end
local frac = 4 / g
if math.abs(frac - math.floor(frac + 0.5)) < 1e-6 then
return ("1/%d"):format(math.floor(frac + 0.5))
end
return ("%.3g QN"):format(g)
end
-- The grid cell the edit cursor currently sits in.
-- Cells are aligned to the project timeline (QN 0), matching Reaper's own
-- grid lines rather than the item start.
-- Returns: startQN, endQN, cellIndex, gridQN
function M.cell(take)
local g = M.gridQN(take)
local qn = reaper.TimeMap2_timeToQN(0, reaper.GetCursorPosition())
local idx = math.floor(qn / g + 1e-9)
return idx * g, (idx + 1) * g, idx, g
end
function M.cellPPQ(take)
local a, b = M.cell(take)
return reaper.MIDI_GetPPQPosFromProjQN(take, a),
reaper.MIDI_GetPPQPosFromProjQN(take, b)
end
-- Move the edit cursor to the start of cell index idx.
function M.gotoCell(take, idx)
local g = M.gridQN(take)
local t = reaper.TimeMap2_QNToTime(0, idx * g)
if t < 0 then t = 0 end
reaper.SetEditCurPos(t, true, false)
end
------------------------------------------------------------- note queries
-- A cell counts as occupied by a note if the note overlaps it at all, so
-- held notes spanning several cells register in every cell they cover.
-- The 1-tick slack keeps float rounding at cell boundaries from producing
-- phantom overlaps.
local SLACK = 1
function M.noteInCell(take, pitch, ppqA, ppqB)
local _, notecnt = reaper.MIDI_CountEvts(take)
for i = 0, notecnt - 1 do
local ok, sel, muted, sppq, eppq, chan, p, vel = reaper.MIDI_GetNote(take, i)
if ok and p == pitch and sppq < ppqB - SLACK and eppq > ppqA + SLACK then
return i, sppq, eppq, chan, vel, sel, muted
end
end
return nil
end
local function noteEndingAt(take, pitch, ppq)
local _, notecnt = reaper.MIDI_CountEvts(take)
for i = 0, notecnt - 1 do
local ok, _, _, sppq, eppq, _, p = reaper.MIDI_GetNote(take, i)
if ok and p == pitch and math.abs(eppq - ppq) <= SLACK then return i, sppq, eppq end
end
return nil
end
local function noteStartingAt(take, pitch, ppq)
local _, notecnt = reaper.MIDI_CountEvts(take)
for i = 0, notecnt - 1 do
local ok, _, _, sppq, eppq, _, p = reaper.MIDI_GetNote(take, i)
if ok and p == pitch and math.abs(sppq - ppq) <= SLACK then return i, sppq, eppq end
end
return nil
end
-- All pitches sounding anywhere inside the current cell, low to high.
function M.pitchesInCell(take, ppqA, ppqB)
local out = {}
local _, notecnt = reaper.MIDI_CountEvts(take)
for i = 0, notecnt - 1 do
local ok, _, muted, sppq, eppq, _, p = reaper.MIDI_GetNote(take, i)
if ok and not muted and sppq < ppqB - SLACK and eppq > ppqA + SLACK then
out[#out + 1] = p
end
end
table.sort(out)
-- de-duplicate unisons on different channels
local uniq = {}
for _, p in ipairs(out) do
if uniq[#uniq] ~= p then uniq[#uniq + 1] = p end
end
return uniq
end
--------------------------------------------------------------------- edits
-- Toggle a note on/off at (current cell, pitch).
-- Returns "on", "off" or nil, plus a describing word for speech.
function M.toggleCell(take, pitch)
local ppqA, ppqB = M.cellPPQ(take)
local idx, sppq, eppq, chan, vel = M.noteInCell(take, pitch, ppqA, ppqB)
reaper.Undo_BeginBlock2(0)
local result, detail
if idx then
-- Cell is occupied: carve this cell out of whatever covers it.
local startsInside = sppq >= ppqA - SLACK
local endsInside = eppq <= ppqB + SLACK
if startsInside and endsInside then
reaper.MIDI_DeleteNote(take, idx)
detail = "removed"
elseif startsInside then
-- held note begins here and continues: shorten from the front
reaper.MIDI_SetNote(take, idx, nil, nil, ppqB, nil, nil, nil, nil, true)
detail = "shortened"
elseif endsInside then
-- held note ends here: shorten from the back
reaper.MIDI_SetNote(take, idx, nil, nil, nil, ppqA, nil, nil, nil, true)
detail = "shortened"
else
-- cell sits mid-way through a held note: split it in two
reaper.MIDI_SetNote(take, idx, nil, nil, nil, ppqA, nil, nil, nil, true)
reaper.MIDI_InsertNote(take, false, false, ppqB, eppq, chan, pitch, vel, true)
detail = "split"
end
result = "off"
else
local v = math.floor(M.getNum("vel", 96))
local c = math.floor(M.getNum("chan", 0))
if M.isHold() then
-- Join with neighbours of the same pitch so runs of adjacent cells
-- become one sustained note rather than repeated attacks.
local pi, _, _ = noteEndingAt(take, pitch, ppqA)
local ni, nsppq, neppq = noteStartingAt(take, pitch, ppqB)
if pi and ni then
reaper.MIDI_SetNote(take, pi, nil, nil, nil, neppq, nil, nil, nil, true)
reaper.MIDI_DeleteNote(take, ni)
detail = "joined"
elseif pi then
reaper.MIDI_SetNote(take, pi, nil, nil, nil, ppqB, nil, nil, nil, true)
detail = "extended"
elseif ni then
reaper.MIDI_SetNote(take, ni, nil, nil, ppqA, nil, nil, nil, nil, true)
detail = "extended"
else
reaper.MIDI_InsertNote(take, false, false, ppqA, ppqB, c, pitch, v, true)
detail = "added"
end
else
reaper.MIDI_InsertNote(take, false, false, ppqA, ppqB, c, pitch, v, true)
detail = "added"
end
result = "on"
end
reaper.MIDI_Sort(take)
reaper.Undo_EndBlock2(0, "MIDI Grid: toggle " .. M.noteName(pitch), -1)
return result, detail
end
------------------------------------------------------------------ audition
--[[
Preview is handled by a separate always-running daemon script rather than
by deferring here. The action scripts must exit immediately: if one is
still alive when you press the same key again, Reaper puts up its
"script is already running" prompt, which makes fast key repeat impossible.
So preview() only posts a request and returns. The daemon owns all
note-on/note-off timing.
]]
function M.preview(pitches, dur)
if not pitches or #pitches == 0 then return end
M.setTemp("pitches", table.concat(pitches, ","))
M.setTemp("dur", dur or M.getNum("previewdur", 0.4))
M.setTemp("seq", (tonumber(M.getTemp("seq", "0")) or 0) + 1)
M.ensureDaemon()
end
-- Start the preview daemon if it is not currently alive. The daemon stamps a
-- wall-clock heartbeat every cycle; anything older than a few seconds means
-- it is gone (Reaper restarted, script terminated) and we relaunch it.
function M.ensureDaemon()
local hb = tonumber(M.getTemp("hb", "0")) or 0
if os.time() - hb <= 3 then return end
local id = reaper.NamedCommandLookup("_RSmidigrid_daemon")
if id and id ~= 0 then reaper.Main_OnCommand(id, 0) end
end
--------------------------------------------------------------------- speech
-- "bar 3 beat 2" style position for the current cell.
function M.cellPosText(take)
local a = select(1, M.cell(take))
local t = reaper.TimeMap2_QNToTime(0, a)
return reaper.format_timestr_pos(t, "", 2)
end
-- Describe the cell contents for speech, e.g. "C5, E5" or "empty".
function M.cellContentText(take)
local ppqA, ppqB = M.cellPPQ(take)
local ps = M.pitchesInCell(take, ppqA, ppqB)
if #ps == 0 then return "empty" end
local names = {}
for _, p in ipairs(ps) do names[#names + 1] = M.noteName(p) end
return table.concat(names, ", ")
end
return M
+70
View File
@@ -0,0 +1,70 @@
-- Stub just enough of the reaper API to exercise the pure logic in the lib.
local ext = { root = "0", scale = "1" }
reaper = {
GetExtState = function(_, k) return ext[k] or "" end,
SetExtState = function(_, k, v) ext[k] = v end,
APIExists = function() return false end,
ShowConsoleMsg = function() end,
SNM_GetIntConfigVar = function(_, d) return 1 end,
GetCursorPosition = function() return 0 end,
TimeMap_GetTimeSigAtTime = function() return 4, 4 end,
}
local G = dofile("D:\\code\\midigrid\\midigrid_lib.lua")
local fails = 0
local function eq(got, want, label)
if got ~= want then
print(("FAIL %s: got %s want %s"):format(label, tostring(got), tostring(want)))
fails = fails + 1
end
end
-- Note naming: with midioctoffs=1, note 60 reads as C5 (Reaper's default).
eq(G.noteName(60), "C5", "noteName 60")
eq(G.noteName(61), "C#5", "noteName 61")
eq(G.noteName(72), "C6", "noteName 72")
eq(G.noteName(48), "C4", "noteName 48")
-- C major: stepping up from C5 walks the scale, not semitones.
ext.root, ext.scale = "0", "1"
local p, seq = 60, {}
for _ = 1, 7 do p = G.scaleStep(p, 1); seq[#seq + 1] = p end
eq(table.concat(seq, ","), "62,64,65,67,69,71,72", "C major ascending")
p, seq = 60, {}
for _ = 1, 3 do p = G.scaleStep(p, -1); seq[#seq + 1] = p end
eq(table.concat(seq, ","), "59,57,55", "C major descending")
-- An off-scale note must not trap the cursor: C#5 in C major steps to D5.
eq(G.scaleStep(61, 1), 62, "off-scale up")
eq(G.scaleStep(61, -1), 60, "off-scale down")
-- Pentatonic skips more than a tone.
ext.scale = "10" -- major pentatonic
eq(G.scaleStep(60, 1), 62, "pentatonic C->D")
eq(G.scaleStep(64, 1), 67, "pentatonic E->G")
-- Chromatic behaves like plain semitones.
ext.scale = "14"
eq(G.scaleStep(60, 1), 61, "chromatic up")
-- Root transposition: A natural minor.
ext.root, ext.scale = "9", "2"
eq(G.inScale(69), true, "A in A minor")
eq(G.inScale(70), false, "A# not in A minor")
eq(G.scaleName(), "A natural minor", "scale name")
-- Range clamping at the extremes of the MIDI range.
ext.root, ext.scale = "0", "1"
eq(G.scaleStep(127, 1), nil, "top of range")
eq(G.scaleStep(0, -1), nil, "bottom of range")
-- Grid labels in 4/4: one bar is 4 QN.
eq(G.gridLabel(4), "1 bar", "grid 1 bar")
eq(G.gridLabel(8), "2 bars", "grid 2 bars")
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")
print(fails == 0 and "ALL PASS" or (fails .. " FAILURES"))