This commit is contained in:
Brent Baccala 2025-12-02 17:19:51 +01:00 committed by GitHub
commit b506496012
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -0,0 +1,395 @@
ardour {
["type"] = "dsp",
name = "MIDI PC/CC",
category = "Utility",
author = "Brent Baccala",
description = [[Handle MIDI PC and CC messages
It only needs to be on one track to hear the MIDI Program Change (PC) and Continuous Controller (CC) messages.
It operates on all of the tracks based on commands set in their comment blocks.
Every track with "MIDI Program #" in its comments in activated when that PC is sent, and is deactivated when any other PC is sent
The program number can be comma-separated ranges, as in "MIDI Program 0-7,9"
Tracks can also be labeled "MIDI Bank # Program #", in which case both bank and program numbers have to match.
Each such track can have MIDI CC lines with arbitrary Lua code.
In such code, variables route (the current route) and value (the CC value) are defined.
The following are working MIDI CC lines.
Mute or unmute the current track:
MIDI CC 93: Session:set_control(route:mute_control(), (value == 0) and 1 or 0, PBD.GroupControlDisposition.NoGroup)
Program blocks are supported. This makes CC 93 role transport:
MIDI CC 93: if value<40 then
MIDI CC 93: Session:request_stop (false, false, ARDOUR.TransportRequestSource.TRS_UI)
MIDI CC 93: else
MIDI CC 93: Session:request_locate(0, false, ARDOUR.LocateTransportDisposition.MustStop, ARDOUR.TransportRequestSource.TRS_UI)
MIDI CC 93: Session:request_roll (ARDOUR.TransportRequestSource.TRS_UI)
MIDI CC 93: end
There's a convenience function to roll transport at a start time in seconds:
MIDI CC 93: roll_transport(value, 0)
You can also roll transport at a marker:
MIDI CC 93: roll_transport(value, "01")
Another convenience function to activate or deactivate a plugin:
MIDI CC 91: activate_processor_by_name(route, value, "Gigaverb")
A convenience function to select a preset:
MIDI CC 93: load_preset(route, "plugin", "preset")
Remember that a "MIDI Program #" (or "MIDI Bank # Program #") line is required for MIDI CC lines to be processed
]]
}
function dsp_ioconfig ()
return { { midi_in = 1, midi_out = 1, audio_in = 0, audio_out = 0}, }
end
--
-- activate_processor_by_name(route, name)
--
-- a convenience function that enables or disables a plugin by name (a string)
-- route should be an Ardour Route object (not a string)
function get_processor_by_name(route, name)
local i = 0;
repeat
local proc = route:nth_plugin (i) -- get Nth Ardour::Processor
if (not proc:isnil()) and proc:display_name() == name then
return proc
end
i = i + 1
until proc:isnil()
return nil
end
function activate_processor_by_name(route, value, name)
local proc = get_processor_by_name(route, name)
if not proc:isnil() then
if value == 0 then
proc:deactivate()
else
proc:activate()
end
end
end
--
-- load_preset(route, plugin, preset)
--
-- a convenience function to load a plugin's preset by name
-- route is an Ardour Route object
-- plugin and preset are strings
function load_preset(route, plugin, preset)
local proc = get_processor_by_name(route, plugin)
if not proc:isnil() then
local pp = proc:to_insert():plugin(0)
pp:load_preset(pp:preset_by_label(preset))
end
end
-- roll_transport(value, location)
--
-- a convenience function to start or stop the transport
-- value is 0 to stop transport and not 0 to start transport
-- location is either a value in seconds or a string matching a marker
function string.starts(String,Start)
return string.sub(String,1,string.len(Start))==Start
end
function roll_transport(value, location)
if value==0 then
Session:request_stop (false, false, ARDOUR.TransportRequestSource.TRS_UI)
else
-- default value of location is 0 (start of session)
local start_sample
if not location then
start_sample = 0
elseif type(location) == "string" then
for loc in Session:locations():list():iter() do
if loc:is_mark() then
if string.starts(loc:name(), location) then
start_sample = loc:start():samples();
end
end
end
else
start_sample = Session:nominal_sample_rate() * location;
end
if start_sample then
Session:request_locate(start_sample, false, ARDOUR.LocateTransportDisposition.MustStop, ARDOUR.TransportRequestSource.TRS_UI)
Session:request_roll (ARDOUR.TransportRequestSource.TRS_UI)
end
end
end
-- the currently active functions on the Continuous Controllers
-- it's a list of pairs, indexed by CC number, each pair a Route object and a Lua function
CC_functions = { }
-- global variables to track current bank and program
bank_msb = 0
bank_lsb = 0
bank = 0
program = 0
-- return true/false if number (an integer) is in range (a string)
-- format of range is a comma-separated list of items, each either START-STOP or VALUE
function match_number_range(number, range)
local fieldstart = 1
local match = false
range = range .. ','
repeat
local r,_,start,stop = string.find(range, "^(%d+)-(%d+)", fieldstart)
if r and number >= tonumber(start) and number <= tonumber(stop) then
match = true
end
r,_,value = string.find(range, "^(%d+)", fieldstart)
if r and number == tonumber(value) then
match = true
end
local nexti = string.find(range, ",", fieldstart)
fieldstart = nexti + 1
until fieldstart > string.len(range)
return match
end
function program_change()
-- Program Change or Bank Change message
-- program and bank are global variables
-- Parse/reparse the comment blocks
-- Clear the global table of CC functions; it'll be recreated during the parse
CC_functions = { }
-- Run through all comment blocks on all routes looking for certain strings
for route in Session:get_routes():iter() do
local nextchar = 1
local MIDI_Program_seen = false
local route_comment = route:comment()
local route_active = false
local local_CC_functions = { }
local program_functions = { } -- to store functions to execute after track activation
-- Look for "MIDI Bank # Program #: code" statements with function calls
while true do
local MIDI_Program_start, MIDI_Program_end, bank_list, program_list, code
MIDI_Program_start,MIDI_Program_end,bank_list,program_list,code = string.find(route_comment, "MIDI Bank (%d[-%d,]*) Program (%d[-%d,]*): ([^\n]+)", nextchar)
if bank_list then
local matches = match_number_range(program, program_list) and match_number_range(bank, bank_list)
route_active = route_active or matches
MIDI_Program_seen = true
if matches and code then
table.insert(program_functions, code)
end
nextchar = MIDI_Program_end + 1
else
break
end
end
-- Look for "MIDI Bank # Program #" statements that match both the program number and the bank number
nextchar = 1
while true do
local MIDI_Program_start, MIDI_Program_end, bank_list, program_list
MIDI_Program_start,MIDI_Program_end,bank_list,program_list = string.find(route_comment, "MIDI Bank (%d[-%d,]*) Program (%d[-%d,]*)", nextchar)
if bank_list then
route_active = route_active or (match_number_range(program, program_list) and match_number_range(bank, bank_list))
MIDI_Program_seen = true
nextchar = MIDI_Program_end + 1
else
break
end
end
-- Look for "MIDI Program #: code" statements with function calls
nextchar = 1
while true do
local MIDI_Program_start, MIDI_Program_end, program_list, code
MIDI_Program_start,MIDI_Program_end,program_list,code = string.find(route_comment, "MIDI Program (%d[-%d,]*): ([^\n]+)", nextchar)
if program_list then
local matches = match_number_range(program, program_list)
route_active = route_active or matches
MIDI_Program_seen = true
if matches and code then
table.insert(program_functions, code)
end
nextchar = MIDI_Program_end + 1
else
break
end
end
-- Look for "MIDI Program #" statements that match just the program number
nextchar = 1
while true do
local MIDI_Program_start, MIDI_Program_end, program_list
MIDI_Program_start,MIDI_Program_end,program_list = string.find(route_comment, "MIDI Program (%d[-%d,]*)", nextchar)
if program_list then
route_active = route_active or match_number_range(program, program_list)
MIDI_Program_seen = true
nextchar = MIDI_Program_end + 1
else
break
end
end
-- if at least one of either kind of line was seen, there is further handling of this track
if MIDI_Program_seen then
-- all tracks with a "MIDI Program" line present are set either active or inactive, depending on if they matched
-- all other tracks (no "MIDI Progam" line) are not affected (they never get to this point)
route:set_active(route_active, nil);
-- if this route is active, execute any program change functions and parse any CC functions in the route's comment block
if route_active then
-- execute program change functions after track activation
for _, code in ipairs(program_functions) do
local func, err = load("return function(route) " .. code .. " end")
if func then
local ok, generated_func = pcall(func)
if ok then
local success, exec_err = pcall(generated_func, route)
if not success then
print("Program function execution error:", exec_err)
end
else
print("Program function generation error:", generated_func)
end
else
print("Program function compilation error:", err)
end
end
local nextchar = 1
local local_CC_functions = { }
-- we wrap the code inside "return function (route, value)" and "end"
-- so that when we pcall it with no argument it returns a function that takes two arguments
-- First parse "MIDI Bank # Program # CC #: code" patterns with bank and program restrictions
while true do
MIDI_CC_start, MIDI_CC_end, bank_list, program_list, CC_num, CC_program = string.find(route_comment, "MIDI Bank (%d[-%d,]*) Program (%d[-%d,]*) CC (%d+): ([^\n]+)", nextchar)
if MIDI_CC_start == nil then break end
local key = CC_num .. "|" .. bank_list .. "|" .. program_list
if (local_CC_functions[key] == nil) then
local_CC_functions[key] = { code = "return function(route, value)\n", bank_list = bank_list, program_list = program_list, CC_num = CC_num }
end
local_CC_functions[key].code = local_CC_functions[key].code .. CC_program .. "\n"
nextchar = MIDI_CC_end + 1
end
-- Then parse "MIDI Program # CC #: code" patterns with only program restrictions
nextchar = 1
while true do
MIDI_CC_start, MIDI_CC_end, program_list, CC_num, CC_program = string.find(route_comment, "MIDI Program (%d[-%d,]*) CC (%d+): ([^\n]+)", nextchar)
if MIDI_CC_start == nil then break end
local key = CC_num .. "||" .. program_list
if (local_CC_functions[key] == nil) then
local_CC_functions[key] = { code = "return function(route, value)\n", bank_list = nil, program_list = program_list, CC_num = CC_num }
end
local_CC_functions[key].code = local_CC_functions[key].code .. CC_program .. "\n"
nextchar = MIDI_CC_end + 1
end
-- Finally parse "MIDI CC #: code" patterns without restrictions
nextchar = 1
while true do
MIDI_CC_start, MIDI_CC_end, CC_num, CC_program = string.find(route_comment, "MIDI CC (%d+): ([^\n]+)", nextchar)
if MIDI_CC_start == nil then break end
if (local_CC_functions[CC_num] == nil) then
local_CC_functions[CC_num] = { code = "return function(route, value)\n", bank_list = nil, program_list = nil, CC_num = CC_num }
end
local_CC_functions[CC_num].code = local_CC_functions[CC_num].code .. CC_program .. "\n"
nextchar = MIDI_CC_end + 1
end
-- done parsing (or at least gathering all of the lines together)
-- now compile the CC functions and insert them in the global table
for key, entry in pairs(local_CC_functions) do
local code = entry.code .. "\nend"
local generator, err = load(code)
if generator then
local ok, func = pcall(generator)
if ok then
local cc_num = tonumber(entry.CC_num)
if not CC_functions[cc_num] then
CC_functions[cc_num] = { }
end
-- Store route, function, and program/bank restrictions
table.insert(CC_functions[cc_num], {
route = route,
func = func,
program_list = entry.program_list,
bank_list = entry.bank_list
})
else
print("Execution error:", func)
end
else
print("Compilation error:", err)
end
end
end
end
end
end
function process_midi_messages()
for _,b in pairs (midiin) do
local t = b["time"] -- t = [ 1 .. n_samples ]
local d = b["data"] -- midi-event data
local event_type
local channel
if #d == 0 then
event_type = -1
channel = -1
else
event_type = d[1] >> 4
channel = d[1] & 15
end
if (event_type == 11) then
-- Continuous Controller message
local num = d[2]
local val = d[3]
-- handle Bank Select messages
if num == 0 or num == 32 then
if num == 0 then
bank_msb = val
else
bank_lsb = val
end
bank = 128 * bank_msb + bank_lsb
program_change()
end
-- if any functions are registered for this CC, call them with their respective Route objects and the value of the controller
local lst = CC_functions[num]
if lst then
for _, entry in ipairs(lst) do
-- Check if program/bank restrictions match current program/bank
local program_match = true
local bank_match = true
if entry.program_list then
program_match = match_number_range(program, entry.program_list)
end
if entry.bank_list then
bank_match = match_number_range(bank, entry.bank_list)
end
-- Only execute if restrictions match (or no restrictions)
if program_match and bank_match then
entry.func(entry.route, val)
end
end
end
end
if (event_type == 12) then
-- Program Change message
program = d[2]
program_change()
end
end
end
function dsp_run (_, _, n_samples)
-- without pcall, any errors would crash Ardour; instead, print errors
status, error = pcall(process_midi_messages)
if not status then
print(error)
end
end