Files
midigrid/midigrid_lib.lua
T
Talon c9e927ca3d Add diatonic chord entry and velocity control
Chords are built by stacking scale thirds rather than from a quality table,
so the chord follows the degree automatically -- in C major, Alt+3 on C gives
C major, on D gives D minor, on B gives B diminished. Toggling delegates to
toggleCell, so chord tones inherit hold-mode joining, splitting and trimming
unchanged.

Velocity keys act on the note under the cursor if there is one, and on the
default for new notes otherwise, announcing which. That avoids a mode switch
between setting a level to draw at and shaping dynamics after the fact.

Also fixes install.ps1 failing to resolve its own directory: $PSScriptRoot is
not reliably populated during parameter binding, so it is resolved in the body.

Tests cover chord quality per scale degree and range clamping.
2026-08-14 18:18:12 +02:00

468 lines
16 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
--------------------------------------------------------------------- chords
-- Stack `count` diatonic chord tones on `pitch`, skipping a scale degree
-- between each -- so in a major scale, degree 1 gives a major triad and
-- degree 2 a minor one, exactly as harmony expects. In the chromatic scale
-- this degenerates to stacked whole tones, which is arguably useless but at
-- least predictable.
function M.chordPitches(pitch, count)
local ps = { pitch }
local p = pitch
for _ = 2, count do
local a = M.scaleStep(p, 1); if not a then return nil end
local b = M.scaleStep(a, 1); if not b then return nil end
p = b
ps[#ps + 1] = p
end
return ps
end
-- Toggle a whole chord in the current cell. If every tone is already
-- present the chord is removed; otherwise the missing tones are filled in.
-- Delegates to toggleCell so hold mode, splitting and trimming all behave
-- identically to single notes.
function M.toggleChord(take, pitches)
local ppqA, ppqB = M.cellPPQ(take)
local allPresent = true
for _, p in ipairs(pitches) do
if not M.noteInCell(take, p, ppqA, ppqB) then allPresent = false break end
end
reaper.Undo_BeginBlock2(0)
local changed = {}
for _, p in ipairs(pitches) do
local here = M.noteInCell(take, p, ppqA, ppqB) ~= nil
if allPresent or not here then
M.toggleCell(take, p)
changed[#changed + 1] = p
end
end
reaper.Undo_EndBlock2(0, "MIDI Grid: toggle chord", -1)
return (allPresent and "off" or "on"), changed
end
------------------------------------------------------------------ velocity
-- Adjust velocity by delta. If a note sits at the cursor cell and pitch, its
-- velocity changes; otherwise the default for newly inserted notes does.
-- Returns "note" or "default", and the resulting value.
function M.nudgeVelocity(take, pitch, delta)
local ppqA, ppqB = M.cellPPQ(take)
local idx, _, _, _, vel = M.noteInCell(take, pitch, ppqA, ppqB)
if idx then
local v = math.max(1, math.min(127, math.floor(vel + delta)))
reaper.Undo_BeginBlock2(0)
reaper.MIDI_SetNote(take, idx, nil, nil, nil, nil, nil, nil, v, true)
reaper.MIDI_Sort(take)
reaper.Undo_EndBlock2(0, "MIDI Grid: set velocity", -1)
return "note", v
end
local v = math.max(1, math.min(127, math.floor(M.getNum("vel", 96) + delta)))
M.set("vel", v)
return "default", v
end
------------------------------------------------------------------ audition
--[[
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