--[[ 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 function M.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 function M.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, _, _ = M.noteEndingAt(take, pitch, ppqA) local ni, nsppq, neppq = M.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 --------------------------------------------------------------- note length --[[ Grow or shrink the note under the cursor by whole grid cells. This is how you sustain notes that already exist. It is deliberately independent of hold mode -- hold mode governs how *new* notes join as you enter them, whereas this edits an existing note's length in place, so it preserves velocity, channel and note identity. Toggling a note off and on again to make it join would silently reset its velocity to the default. Extending swallows any same-pitch notes it runs into, absorbing their tail if they reach further than the new end, so growing a note across repeated notes fuses the run rather than producing overlaps. ]] function M.resizeNote(take, pitch, cells) local ppqA, ppqB = M.cellPPQ(take) local idx, sppq, eppq = M.noteInCell(take, pitch, ppqA, ppqB) if not idx then return nil end local g = M.gridQN(take) local startQN = reaper.MIDI_GetProjQNFromPPQPos(take, sppq) local endQN = reaper.MIDI_GetProjQNFromPPQPos(take, eppq) local newEndQN = endQN + cells * g local clamped = false if newEndQN < startQN + g - 1e-9 then newEndQN = startQN + g -- never shrink below a single cell clamped = true end local newEnd = reaper.MIDI_GetPPQPosFromProjQN(take, newEndQN) reaper.Undo_BeginBlock2(0) local absorbed = 0 if cells > 0 then -- Collect first, delete descending: deleting shifts later indices. local victims = {} local _, notecnt = reaper.MIDI_CountEvts(take) for i = 0, notecnt - 1 do local ok, _, _, s, e, _, p = reaper.MIDI_GetNote(take, i) if ok and p == pitch and i ~= idx and s > sppq + SLACK and s < newEnd - SLACK then victims[#victims + 1] = i if e > newEnd then newEnd = e end end end table.sort(victims, function(a, b) return a > b end) for _, i in ipairs(victims) do reaper.MIDI_DeleteNote(take, i) if i < idx then idx = idx - 1 end absorbed = absorbed + 1 end end reaper.MIDI_SetNote(take, idx, nil, nil, nil, newEnd, nil, nil, nil, true) reaper.MIDI_Sort(take) reaper.Undo_EndBlock2(0, "MIDI Grid: resize note", -1) local lenCells = (reaper.MIDI_GetProjQNFromPPQPos(take, newEnd) - startQN) / g return math.floor(lenCells + 0.5), absorbed, clamped end -- Fuse every contiguous same-pitch note touching the one under the cursor -- into a single sustained note. Returns how many notes were merged. function M.joinRun(take, pitch) local ppqA, ppqB = M.cellPPQ(take) local idx, sppq, eppq = M.noteInCell(take, pitch, ppqA, ppqB) if not idx then return nil end local minS, maxE = sppq, eppq local victims = {} local seen = { [idx] = true } while true do local i, s = M.noteEndingAt(take, pitch, minS) if not i or seen[i] then break end seen[i] = true ; victims[#victims + 1] = i ; minS = s end while true do local i, _, e = M.noteStartingAt(take, pitch, maxE) if not i or seen[i] then break end seen[i] = true ; victims[#victims + 1] = i ; maxE = e end if #victims == 0 then return 0 end reaper.Undo_BeginBlock2(0) table.sort(victims, function(a, b) return a > b end) for _, i in ipairs(victims) do reaper.MIDI_DeleteNote(take, i) end -- Indices shifted; re-find the survivor by its unchanged start position. local keep = M.noteStartingAt(take, pitch, sppq) if keep then reaper.MIDI_SetNote(take, keep, nil, nil, minS, maxE, nil, nil, nil, true) end reaper.MIDI_Sort(take) reaper.Undo_EndBlock2(0, "MIDI Grid: join notes", -1) return #victims + 1 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