install.ps1 could never have worked outside Windows: it reads %APPDATA% for the resource path, guards on Get-Process, and needs PowerShell installed. install.sh mirrors it exactly -- same actions, same key table, same idempotent rewrite and backup -- differing only in resource-path discovery and --real-control, which exists because modifier bit 8 means Ctrl on Windows but Command on macOS. Also document installing by hand from the action list, for anyone who would rather not run either script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
410 lines
17 KiB
Markdown
410 lines
17 KiB
Markdown
# 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 the installer for your platform:
|
||
|
||
**Windows**
|
||
|
||
```powershell
|
||
powershell -ExecutionPolicy Bypass -File install.ps1
|
||
```
|
||
|
||
**macOS / Linux**
|
||
|
||
```bash
|
||
chmod +x install.sh # first time only
|
||
./install.sh
|
||
```
|
||
|
||
3. Start REAPER, select a MIDI item, press **`Alt+Shift+G`** (`Option+Shift+G`
|
||
on a Mac).
|
||
|
||
You should hear something like *"Grid mode on, C major, grid 1/16, hold off,
|
||
C5, 1.1.00"*.
|
||
|
||
To remove it again:
|
||
|
||
```powershell
|
||
powershell -ExecutionPolicy Bypass -File install.ps1 -Uninstall # Windows
|
||
```
|
||
```bash
|
||
./install.sh --uninstall # macOS / Linux
|
||
```
|
||
|
||
Both installers back up `reaper-kb.ini` to `reaper-kb.ini.midigrid-backup`
|
||
before their first change, and are safe to re-run.
|
||
|
||
### macOS notes
|
||
|
||
`install.sh` does exactly what `install.ps1` does — same actions, same
|
||
bindings — but finds the resource folder at
|
||
`~/Library/Application Support/REAPER` (falling back to `~/.config/REAPER` on
|
||
Linux). If yours is elsewhere, find it via **Options → Show REAPER resource
|
||
path** and pass it:
|
||
|
||
```bash
|
||
./install.sh --resource-path "/path/to/REAPER"
|
||
```
|
||
|
||
**Modifiers.** In `reaper-kb.ini` the modifier bit that means Ctrl on Windows
|
||
means **Command** on macOS; the physical Control key is a different bit. So by
|
||
default every `Ctrl+…` binding in the [key table](#keys) is `Command+…` on a
|
||
Mac, which is what Mac users expect. To bind the physical Control key instead:
|
||
|
||
```bash
|
||
./install.sh --real-control
|
||
```
|
||
|
||
`Alt` in the key table is the `Option` key on a Mac either way.
|
||
|
||
### Manual installation
|
||
|
||
If you would rather not run a script — or you want a different key layout —
|
||
you can do the whole thing from REAPER's UI. Nothing about MIDI Grid depends
|
||
on the installer; it only writes action registrations and key bindings.
|
||
|
||
1. **Register the scripts.** In REAPER, open **Actions → Show action list**,
|
||
set the section selector to **Main**, and choose **New action → Load
|
||
ReaScript…**. Select these two files from wherever you put the repository:
|
||
|
||
- `MidiGrid_OpenInGrid.lua`
|
||
- `MidiGrid_Daemon.lua`
|
||
|
||
Then switch the section selector to **MIDI Editor** and load the remaining
|
||
`MidiGrid_*.lua` files the same way (everything except `midigrid_lib.lua`
|
||
and `test_lib.lua`, which are not actions). The section matters: an action
|
||
loaded into the wrong section will not be offered for the keys you want.
|
||
|
||
2. **Bind the keys.** Still in the action list, select an action, click
|
||
**Add…** under Shortcuts, and press the key combination. Use the
|
||
[key table](#keys) below for the intended layout; you can of course choose
|
||
your own. Only `Alt+Shift+G` needs to be in the Main section — everything
|
||
else belongs in MIDI Editor.
|
||
|
||
3. **Say yes when REAPER warns about a key that is already bound.** Grid mode
|
||
deliberately takes over `Left`, `Right`, `Up`, `Down` and `Enter` in the
|
||
MIDI editor; each of those forwards to its previous behaviour when grid
|
||
mode is off, so nothing is lost.
|
||
|
||
You do not need to bind everything. The minimum useful set is
|
||
`Alt+Shift+G` (open), `Alt+G` (toggle), the four arrows, and `Enter`.
|
||
`MidiGrid_Daemon.lua` needs to be **registered** as an action but never needs
|
||
a key — it auto-starts.
|
||
|
||
### 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`). Both installers write the underscored form directly.
|
||
|
||
If you would rather not run a script, see [Manual installation](#manual-installation)
|
||
above. Binding by hand in the action list 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 |
|
||
| `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 | — |
|
||
| `L` / `Shift+L` | Lengthen / shorten note by one cell | Edit: Lengthen / shorten notes one grid unit |
|
||
| `Alt+J` | Join a run of repeated notes into one | — |
|
||
| `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+Space` | Play from start of the current bar, onwards | works in both modes |
|
||
| `Ctrl+Alt+Space` | Play the current bar only, then stop | works in both modes |
|
||
| `Ctrl+Alt+Shift+Space` | Loop the current bar (exact) | works in both modes |
|
||
| `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.
|
||
|
||
Hold mode governs **entry only** — it never retroactively merges notes that
|
||
are already there. To sustain what you have already written, use the length
|
||
keys below.
|
||
|
||
## Note length — sustaining notes you already entered
|
||
|
||
- **`L`** — lengthen the note under the cursor by one grid cell.
|
||
- **`Shift+L`** — shorten it by one cell. It never shrinks below a single
|
||
cell; it says *"minimum"* rather than deleting the note.
|
||
- **`Alt+J`** — fuse an entire run of repeated notes at this pitch into one
|
||
sustained note, in a single keystroke.
|
||
|
||
Lengthening **swallows** any same-pitch note it runs into, taking that note's
|
||
tail if it reached further. So pressing `L` across a row of repeated notes
|
||
fuses them rather than producing overlaps.
|
||
|
||
Each press reports the resulting length — *"E5, 3 cells"*, or *"E5, 2 cells,
|
||
joined 1"* when it absorbed something.
|
||
|
||
### Why not re-toggle with hold on instead?
|
||
|
||
Turning hold on and re-entering existing notes does work. But toggling a note
|
||
off and on **deletes and recreates it, resetting its velocity to the default**
|
||
— if you have shaped dynamics, that quietly discards them.
|
||
|
||
The length keys edit the note in place, so velocity, channel and note identity
|
||
survive. They also need no mode: `L` behaves identically whether hold is on or
|
||
off, so there is no state to set up beforehand and none to remember to turn
|
||
off afterwards.
|
||
|
||
## Hearing it in context
|
||
|
||
Both of these play from the **top of the bar the cursor is in**, and say which
|
||
bar:
|
||
|
||
- **`Alt+Space`** — plays from the bar line and keeps going. Stop with `Space`.
|
||
- **`Ctrl+Alt+Space`** — plays that bar **only**, stopping just before the next
|
||
bar line. Nothing to stop by hand, so you can fire it repeatedly while
|
||
nudging a note and hear the same bar each time.
|
||
- **`Ctrl+Alt+Shift+Space`** — **loops** that bar, exactly. Repeats until you
|
||
press `Space`.
|
||
|
||
### Which of the two bar actions to use
|
||
|
||
`Ctrl+Alt+Space` stops by polling, and a polling loop can only be as precise as
|
||
its own interval — about 33 ms at frame rate. It therefore stops a little
|
||
early, and notes falling inside that window do not sound. In practice:
|
||
|
||
| Tempo | 16th note | 32nd note | 35 ms lead is |
|
||
|---|---|---|---|
|
||
| 120 BPM | 125 ms | 62 ms | 0.28 of a 16th, 0.56 of a 32nd |
|
||
| 180 BPM | 83 ms | 42 ms | 0.42 of a 16th, 0.84 of a 32nd |
|
||
| 240 BPM | 62 ms | 31 ms | 0.56 of a 16th, 1.12 of a 32nd |
|
||
|
||
So it is safe for 16ths at any sane tempo, and safe for 32nds up to about
|
||
214 BPM. Past that, the final 32nd of the bar is inside the lead and will not
|
||
be heard.
|
||
|
||
`Ctrl+Alt+Shift+Space` has no such limit. It hands the bar boundary to REAPER's
|
||
audio engine through the loop points, which is sample accurate, so the next
|
||
bar's downbeat can never leak in at any tempo or grid size. Use it when you are
|
||
working at 32nds or faster, or whenever you want certainty. Looping also suits
|
||
the job well — leave it running and edit while it plays.
|
||
|
||
It borrows the project's loop range and repeat state, and the daemon restores
|
||
both once transport stops, including when you stop with `Space`.
|
||
|
||
Auditioning a cell tells you what a note *is*; playing from the downbeat tells
|
||
you whether it is in the right *place*. At 16ths or 32nds that difference is
|
||
the whole question, and it cannot be judged from a note in isolation.
|
||
|
||
Your position is preserved. The edit cursor doubles as the grid cursor, so it
|
||
is moved to the bar line only long enough to start playback and then put
|
||
straight back — you carry on editing exactly where you were.
|
||
|
||
Both work whether or not grid mode is on, since they are listening aids rather
|
||
than editing actions.
|
||
|
||
The bar-only stop is carried out by the preview daemon, not by the action
|
||
script — a script that stayed alive waiting for the bar to end would trigger
|
||
REAPER's "already running" prompt on the next press. Stopping can move the
|
||
edit cursor depending on your preferences, so the daemon puts it back.
|
||
|
||
It stops **35 ms before** the bar line rather than on it, or a note sitting
|
||
exactly on the next bar line would sound. The test uses `GetPlayPosition2`,
|
||
which reports the block REAPER is about to render rather than what you are
|
||
currently hearing — that removes render-ahead from the equation, so the lead
|
||
only has to cover the daemon's own polling interval. (Testing against
|
||
`GetPlayPosition` instead needed 60 ms to cover both, which was enough to
|
||
swallow a whole 32nd note from 125 BPM upward.)
|
||
|
||
`stoplead` in `ExtState` tunes it. Raising it is the fix if a next-bar note
|
||
still sneaks through; lowering it recovers a little more of the bar's tail at
|
||
the risk of leaks.
|
||
|
||
## Shifting notes in time
|
||
|
||
If a whole clip landed a couple of cells off the beat, `Ctrl+Shift+Left` and
|
||
`Ctrl+Shift+Right` move **every note in the take** by one grid cell.
|
||
|
||
Because the entire take moves together, notes can never collide with each
|
||
other. What is at stake is the **item boundary**: notes pushed past either end
|
||
stop sounding. They are *not* deleted — shifting back the other way brings
|
||
them straight back — but a clip that has quietly lost its first bar is not
|
||
something you want to discover two hours later.
|
||
|
||
So the plain keys **refuse** the shift if any note would end up outside, and
|
||
say how many: *"Not shifted left, 3 notes would fall outside the item. Add Alt
|
||
to force."*
|
||
|
||
Adding **Alt** (`Ctrl+Alt+Shift+Left` / `Right`) does it anyway, reporting the
|
||
consequence: *"Shifted left, 3 notes now outside the item and silent."*
|
||
|
||
Positions are converted through quarter-notes rather than offset in raw ticks,
|
||
so a shift stays musically correct across tempo changes.
|
||
|
||
## 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
|
||
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 |
|
||
| `velstep` | 8 | Velocity change per keypress |
|
||
| `stoplead` | 0.035 | How early to stop, in seconds, when playing one bar |
|
||
| `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.
|