--[[ 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 ------------------------------------------------------------------ playback -- Start and end of the bar containing time t, plus the 1-based bar number. function M.barBounds(t) local _, measures = reaper.TimeMap2_timeToBeats(0, t) return reaper.TimeMap2_beatsToTime(0, 0, measures), reaper.TimeMap2_beatsToTime(0, 0, measures + 1), measures + 1 end --[[ Play from the top of the bar the cursor is in, without losing your place. The edit cursor doubles as the grid cursor, so it is moved to the bar line only long enough to start playback and then put straight back. Restoring it passes seekplay=false so the restore cannot drag playback along with it. Playing from the bar line rather than the cursor is what makes fine subdivisions checkable: you hear the note in relation to the downbeat, which is the thing you are actually trying to judge. ]] function M.playFromBar(stopAtBarEnd) local here = reaper.GetCursorPosition() local barPos, barEnd, barNum = M.barBounds(here) reaper.PreventUIRefresh(1) reaper.SetEditCurPos(barPos, false, true) -- GetPlayState bit 0 is "playing"; avoid bitwise ops for portability. if (reaper.GetPlayState() % 2) == 0 then reaper.CSurf_OnPlay() end reaper.SetEditCurPos(here, false, false) reaper.PreventUIRefresh(-1) if stopAtBarEnd then -- The daemon watches for this and stops transport, because an action -- script must never stay alive to wait for it. M.setTemp("stopat", barEnd) -- Grace period: play state takes a moment to report as playing, and -- without this the daemon would see "not playing" and cancel instantly. M.setTemp("stoparm", reaper.time_precise() + 0.5) M.ensureDaemon() else M.setTemp("stopat", "") end return barNum end ---------------------------------------------------------------- time shift --[[ Move every note in the take by whole grid cells. Because the entire take moves together, notes cannot collide with each other -- the only thing at stake is the item's boundaries. Notes pushed outside them are not deleted, they simply stop sounding, and shifting back the other way restores them. The safe variant still refuses, because silently silencing part of a clip is exactly the kind of thing you want to be told about rather than discover later. Positions are converted through QN rather than offset in ticks, so a shift stays musically correct across tempo changes. Returns moved, outside. moved is nil when the shift was refused. ]] function M.shiftAllNotes(take, cells, force) local _, notecnt = reaper.MIDI_CountEvts(take) if notecnt == 0 then return 0, 0 end local g = M.gridQN(take) local item = reaper.GetMediaItemTake_Item(take) local itemPos = reaper.GetMediaItemInfo_Value(item, "D_POSITION") local itemLen = reaper.GetMediaItemInfo_Value(item, "D_LENGTH") local loPPQ = reaper.MIDI_GetPPQPosFromProjTime(take, itemPos) local hiPPQ = reaper.MIDI_GetPPQPosFromProjTime(take, itemPos + itemLen) local moved, outside = {}, 0 for i = 0, notecnt - 1 do local ok, _, _, s, e = reaper.MIDI_GetNote(take, i) if ok then local ns = reaper.MIDI_GetPPQPosFromProjQN(take, reaper.MIDI_GetProjQNFromPPQPos(take, s) + cells * g) local ne = reaper.MIDI_GetPPQPosFromProjQN(take, reaper.MIDI_GetProjQNFromPPQPos(take, e) + cells * g) moved[i] = { ns, ne } if ns < loPPQ - SLACK or ne > hiPPQ + SLACK then outside = outside + 1 end end end if outside > 0 and not force then return nil, outside end reaper.Undo_BeginBlock2(0) for i = 0, notecnt - 1 do local np = moved[i] if np then reaper.MIDI_SetNote(take, i, nil, nil, np[1], np[2], nil, nil, nil, true) end end reaper.MIDI_Sort(take) reaper.Undo_EndBlock2(0, "MIDI Grid: shift notes", -1) return notecnt, outside end -- Shared driver for the four shift actions: does the work and the speech. function M.runShift(cells, force) local hwnd, take, err = M.editor() if not take then M.say(err) return end local moved, outside = M.shiftAllNotes(take, cells, force) local dir = cells < 0 and "left" or "right" if moved == nil then M.say(("Not shifted %s, %d %s would fall outside the item. Add Alt to force.") :format(dir, outside, outside == 1 and "note" or "notes")) elseif moved == 0 then M.say("No notes to shift") elseif outside > 0 then M.say(("Shifted %s, %d %s now outside the item and silent") :format(dir, outside, outside == 1 and "note" or "notes")) else M.say(("Shifted %s, %d %s"):format(dir, moved, moved == 1 and "note" or "notes")) end 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