From 8fd6f947edaca33d16dc4600e6761883f4e51d30 Mon Sep 17 00:00:00 2001 From: Talon Date: Fri, 14 Aug 2026 18:13:45 +0200 Subject: [PATCH] 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. --- .gitignore | 2 + MidiGrid.ReaperKeyMap | 21 ++ MidiGrid_AuditionCell.lua | 13 ++ MidiGrid_Daemon.lua | 57 ++++++ MidiGrid_Down.lua | 17 ++ MidiGrid_Left.lua | 18 ++ MidiGrid_OpenInGrid.lua | 40 ++++ MidiGrid_Right.lua | 17 ++ MidiGrid_SetScale.lua | 35 ++++ MidiGrid_ToggleCell.lua | 18 ++ MidiGrid_ToggleHold.lua | 7 + MidiGrid_ToggleMode.lua | 34 ++++ MidiGrid_Up.lua | 17 ++ README.md | 171 ++++++++++++++++ install.ps1 | 106 ++++++++++ midigrid_lib.lua | 403 ++++++++++++++++++++++++++++++++++++++ test_lib.lua | 70 +++++++ 17 files changed, 1046 insertions(+) create mode 100644 .gitignore create mode 100644 MidiGrid.ReaperKeyMap create mode 100644 MidiGrid_AuditionCell.lua create mode 100644 MidiGrid_Daemon.lua create mode 100644 MidiGrid_Down.lua create mode 100644 MidiGrid_Left.lua create mode 100644 MidiGrid_OpenInGrid.lua create mode 100644 MidiGrid_Right.lua create mode 100644 MidiGrid_SetScale.lua create mode 100644 MidiGrid_ToggleCell.lua create mode 100644 MidiGrid_ToggleHold.lua create mode 100644 MidiGrid_ToggleMode.lua create mode 100644 MidiGrid_Up.lua create mode 100644 README.md create mode 100644 install.ps1 create mode 100644 midigrid_lib.lua create mode 100644 test_lib.lua diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..87f85ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.bak +*.midigrid-backup diff --git a/MidiGrid.ReaperKeyMap b/MidiGrid.ReaperKeyMap new file mode 100644 index 0000000..6166694 --- /dev/null +++ b/MidiGrid.ReaperKeyMap @@ -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 diff --git a/MidiGrid_AuditionCell.lua b/MidiGrid_AuditionCell.lua new file mode 100644 index 0000000..981c792 --- /dev/null +++ b/MidiGrid_AuditionCell.lua @@ -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) diff --git a/MidiGrid_Daemon.lua b/MidiGrid_Daemon.lua new file mode 100644 index 0000000..b3569d1 --- /dev/null +++ b/MidiGrid_Daemon.lua @@ -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() diff --git a/MidiGrid_Down.lua b/MidiGrid_Down.lua new file mode 100644 index 0000000..bf185ba --- /dev/null +++ b/MidiGrid_Down.lua @@ -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 }) diff --git a/MidiGrid_Left.lua b/MidiGrid_Left.lua new file mode 100644 index 0000000..41bc202 --- /dev/null +++ b/MidiGrid_Left.lua @@ -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)) diff --git a/MidiGrid_OpenInGrid.lua b/MidiGrid_OpenInGrid.lua new file mode 100644 index 0000000..ae8b259 --- /dev/null +++ b/MidiGrid_OpenInGrid.lua @@ -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))) diff --git a/MidiGrid_Right.lua b/MidiGrid_Right.lua new file mode 100644 index 0000000..9f5024f --- /dev/null +++ b/MidiGrid_Right.lua @@ -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)) diff --git a/MidiGrid_SetScale.lua b/MidiGrid_SetScale.lua new file mode 100644 index 0000000..24c5abb --- /dev/null +++ b/MidiGrid_SetScale.lua @@ -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()) diff --git a/MidiGrid_ToggleCell.lua b/MidiGrid_ToggleCell.lua new file mode 100644 index 0000000..6962920 --- /dev/null +++ b/MidiGrid_ToggleCell.lua @@ -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 diff --git a/MidiGrid_ToggleHold.lua b/MidiGrid_ToggleHold.lua new file mode 100644 index 0000000..00dbd90 --- /dev/null +++ b/MidiGrid_ToggleHold.lua @@ -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") diff --git a/MidiGrid_ToggleMode.lua b/MidiGrid_ToggleMode.lua new file mode 100644 index 0000000..485ab52 --- /dev/null +++ b/MidiGrid_ToggleMode.lua @@ -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))) diff --git a/MidiGrid_Up.lua b/MidiGrid_Up.lua new file mode 100644 index 0000000..b909da5 --- /dev/null +++ b/MidiGrid_Up.lua @@ -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 }) diff --git a/README.md b/README.md new file mode 100644 index 0000000..373db65 --- /dev/null +++ b/README.md @@ -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. diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..7242977 --- /dev/null +++ b/install.ps1 @@ -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." diff --git a/midigrid_lib.lua b/midigrid_lib.lua new file mode 100644 index 0000000..51fbf67 --- /dev/null +++ b/midigrid_lib.lua @@ -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 diff --git a/test_lib.lua b/test_lib.lua new file mode 100644 index 0000000..e38b937 --- /dev/null +++ b/test_lib.lua @@ -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"))