2026-08-14 18:13:45 +02:00
|
|
|
--[[
|
|
|
|
|
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
|
|
|
|
|
|
2026-08-14 18:18:12 +02:00
|
|
|
--------------------------------------------------------------------- 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
|
|
|
|
|
|
2026-08-14 18:13:45 +02:00
|
|
|
------------------------------------------------------------------ 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
|