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.
404 lines
14 KiB
Lua
404 lines
14 KiB
Lua
--[[
|
|
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
|