Files
midigrid/midigrid_lib.lua
T

468 lines
16 KiB
Lua
Raw Normal View History

--[[
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