emuell / afseq

GNU Affero General Public License v3.0
2 stars 1 forks source link

Rhythm templates, Controlling rhythms via external parameter changes #32

Closed emuell closed 2 months ago

emuell commented 4 months ago

Motivation

It should be possible to automate, change "things" in rhythms from outside - dynamically. So you can change a running note pattern with a macro or MIDI controller, or can create more generic script presets which then can act as templates, e.g. select a scale, pattern variations, or volume, panning values in scripts.

API Additions

Right now, the only way to control a rhythm's output from the outside is to use the TriggerContext's trigger_note / volume / offset parameters. In order to configure a rhythms behaviour more freely, we could introduce something like a custom input parameter layer for rhythms there too:

---@class TriggerContext
---
---[...]
---
---Input parameter values.
---@see InputParameter
---@field inputs { [string]: number }

InputParameter is defined as:

---@alias InputParameterType 
---|"boolean" a on/off value which flips between the specified range min/max
---|"float" a real value without a quantize step within the specified range
---|"integer" a value which refers to an integer value in the specified integer range. 
---|"enum" a string value using a fixed list of strings as values. 

---@class InputParameter
---Unique id of the parameter. The id will be used in the `input` context table as key.
---@field id string 
---Optional name of the parameter as displayed to the user. When undefined the id is used.
---@field name? string
---Optional long description of the parameter describing what the parameter does.
---@field description? string
---Defines the value type and display options (boolean -> switch, float -> slider ...).
---@field type? InputParameterType
---Valid value range. Default: (0 - 1)
---@field range? { min: number, max: number }
---Default value. Default: 0.0
---@field default? number
---Optional custom string -> parameter value conversion for the UI.
---@field to_string? fun(value: number):string
---Optional custom parameter value -> string conversion for the UI.
---@field from_string? fun(value: string):number
local InputParameter = {}

To inject/define the set of inputs, the rhythm constructor will then get a new optional inputs table property:

---@class RhythmOptions
---
---[...]
---
---Define optional input parameters for the rhythm. Input parameters can dynamically change a rhythms
---behavior everywhere where `context` are passed, e.g. in pattern, gate, emitter generator functions or
---cycle mapping functions.
---@field inputs? InputParameter[]

Input parameter definitions are quite complex & verbose. To make it easier to construct them, there should be helper functions to quickly create common parameters, such as:

---Shortcut for creating a InputParameter with  "integer" type and the given other properties
function integer_input(id, min_max, default, description) end
---Shortcut for creating a InputParameter with  "enum" type and the given other properties
function enum_input(id, string_values, default, description) end
---Shortcut for creating an enum InputParameter with all available scale modes as values
function scale_input(id, default, description) end
---Shortcut for creating an enum InputParameter with key strings (c .. b)
function base_note_input(id, default, description) end
...

Examples

Template which allows creating euclidean patterns with custom step, pulse settings:

return rhythm {
  inputs = {
    integer_input('steps', {1, 64}, 12, "Number of on steps in the pattern"),
    integer_input('pulses', {1, 64}, 16, "Total number of on & off pulses"),
    integer_input('offset', {-16, 16}, 0, "Rotates on pattern left (values > 0) or right (values < 0)"),
  },
  unit = "1/1",
  pattern = function(context)
    return pattern.euclidean(context.inputs.steps, context.inputs.pulses, context.inputs.offset)
  end,
  emit = "c4"
}

Arp pattern with custom scale settings, direction and length:

local function generate_arp_pattern(scale, length)
  local i, o = 1, 1
  return pattern.new(length):map(function(_)
    local note = scale.notes[i] + o * 12
    i = i + 1 -- wrap around in octaves
    if i > #scale.notes then
      o = o + 1
      i = 1
    end
    return note
  end)      
end

return rhythm {
  inputs = {
    scale_input('mode', "Major", "Mode of the arp's scale"),
    base_note_input('base_note', "c4", "Base note of the arp's scale"),
    integer_input('length', [3, 16] 8, "Number of notes in the arp"),
    boolean_input('reverse', false, "Set to true to reverse the arp direction"),
  },
  unit = "1/16",
  emit = function(context) 
    local notes = generate_arp_pattern(
      scale(context.inputs.base_note, context.inputs.mode), 
      context.inputs.length
    )
    local step = reverse and #notes or 1
    local reverse = context.inputs.reverse
    return function(context)
      local note = notes[step] 
      if reverse then
        step = math.imod(step - 1 + #notes, #notes) 
      else
        step = math.imod(step + 1, #notes) 
      end
      return note
    end
  end
}

Random bass note pattern generator with 100 variations (seeds)

return rhythm {
  inputs = {
    integer_input('variation', {1, 100}, 1, "Random pattern variation number"),
  },
  unit = "1/16",
  pattern = pattern.euclidean(6, 16, 0),
  emit = function(context)
    local pmin = scale("c5", "pentatonic minor").notes
    local rand = math.randomstate(127364 + context.inputs.variation)
    local notes = pattern.new(12):map(function(_)
      return pmin[rand(#pmin)]
    end)
    return notes[math.imod(context.step, #notes)]
  end
}

Limitations

emuell commented 2 months ago

This is now done with the following changes (compared to the original specs):

---@class InputParameter is an opaque userdata type without any public functions.

It can only be constructed with a set of helper functions, which are grouped together in a new parameters "namespace":

---Functions to create InputParameters.
parameter = {
    ---Creates an InputParameter with "boolean" Lua type with the given default value
    ---and other optional properties.
    ---@param id InputParameterId
    ---@param default InputParameterBooleanDefault
    ---@param name InputParameterName?
    ---@param description InputParameterDescription?
    ---@return InputParameter
    boolean = function(id, default, name, description) end,

    ---Creates an InputParameter with "integer" Lua type with the given default value
    ---and other optional properties.
    ---@param id InputParameterId
    ---@param default InputParameterIntegerDefault
    ---@param range InputParameterIntegerRange?
    ---@param name InputParameterName?
    ---@param description InputParameterDescription?
    ---@return InputParameter
    integer = function(id, default, range, name, description) end,

    ---Creates an InputParameter with "number" Lua type with the given default value
    ---and other optional properties.
    ---@param id InputParameterId
    ---@param default InputParameterNumberDefault
    ---@param range InputParameterNumberRange?
    ---@param name InputParameterName?
    ---@param description InputParameterDescription?
    ---@return InputParameter
    number = function(id, default, range, name, description) end,

    ---Creates an InputParameter with a "string" Lua type with the given default value,
    ---set of valid values to choose from and other optional properties.
    ---@param id InputParameterId
    ---@param default InputParameterEnumDefault
    ---@param values string[]
    ---@param name InputParameterName?
    ---@param description InputParameterDescription?
    ---@return InputParameter
    enum = function(id, default, values, name, description) end,
}